Merge origin/main into selfhost main
This commit is contained in:
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
||||
|
||||
### Version format
|
||||
|
||||
The current source-of-truth version is **2.1.0 (build 110)**. Releases use stable SemVer:
|
||||
The current source-of-truth version is **2.1.6 (build 131)**. Releases use stable SemVer:
|
||||
|
||||
| Field | File | Rule |
|
||||
|-------|------|------|
|
||||
|
||||
+56
-14
@@ -7,12 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Auto-return to the previous app in every build**: host-app identification used to compile only in Debug, so TestFlight and App Store builds never knew which app you were typing in and could not jump back after starting a voice session. It now runs in Debug, TestFlight, and Release; if the system does not expose the host (iOS earlier than 26.4), OSG simply stays open as before. / **所有版本都能自动跳回原 App**:此前识别宿主 App 的逻辑只在 Debug 构建中编译,TestFlight 和 App Store 版本不知道你正在哪个 App 里输入,启动语音会话后无法自动跳回。现在 Debug、TestFlight、正式版都会识别;系统不提供宿主信息时(低于 iOS 26.4)与之前一样停留在 OSG。
|
||||
|
||||
## [2.1.6] - 2026-10-27
|
||||
|
||||
### Added
|
||||
- **Translate to your target language**: when the copied text is in your device language and a translation target language is set, the skill bar shows a "Translate <language>" button (for example "翻译英语" / "To Japanese") that translates the copy into that target. It is hidden when translation is off or the target is the same language as the text. / **翻译为目标语言**:复制的文字是设备语言、且已设置翻译目标语言时,技能栏会出现「翻译 XX」按钮(如「翻译英语」/「To Japanese」),点击后把内容译成目标语言;翻译关闭,或目标语言与原文相同时不显示。
|
||||
- **Polish foreign-language text**: when the copied text is in another language, the skill bar adds a "Polish" button that fixes grammar, improves wording, and polishes the text in one pass while keeping the original language. Automatic translation of foreign text keeps using the existing "auto translate on keyboard open" switch. / **优化外语文本**:复制的文字是其他语言时,技能栏新增「优化」按钮,一次完成语法纠错、措辞优化和润色,结果保持原语言。外语内容的自动翻译仍使用现有的「打开键盘自动翻译」开关。
|
||||
|
||||
### Changed
|
||||
- **Messaging shift to AI voice keyboard / voice double / agentic skills**: README and the product page now lead with “Say it. It’s typed. Speaks like you. Acts for you.” — the personalization story (learned style, three-tone replies) moves ahead of typing, clipboard skills are framed as agentic actions, and typing becomes the fallback capability. / **宣传定位转向 AI 语音键盘 / 嘴替 / Agentic 技能**:README 与产品页改用「开口即文字。会说你的话,会办你的事。」定位;个人化卖点(专属风格、三语气回复)提前,剪贴板技能表述升级为 Agentic 行动,打字退为兜底能力。
|
||||
- **Product page redesign**: rebuild the GitHub Pages home as a dark product stage with the new Icon Composer app icon, the three-device hero visual, and real on-device videos and UI cards replacing hand-drawn mockups; add 2.1 selling points (personal style, three-tone replies, monthly usage calendar, EventKit skills export, guided trials and credit rewards), a TestFlight link, and self-hosted inline SVG icons; bump page references to 2.1.0. / **产品页重设计**:GitHub Pages 主页改为深色产品舞台,启用 Icon Composer 新图标与三端同框主视觉,以实机视频与真实界面卡片取代手绘示意;补齐 2.1 卖点(专属风格、三语气个性回复、月度用量日历、EventKit 技能写入、引导体验与积分奖励),新增 TestFlight 入口,图标改为自托管内联 SVG;页面版本号更新至 2.1.0。
|
||||
- **Host-only analytics recording**: the keyboard extension no longer opens the analytics or keyboard-usage SQLite queues. The host app is the only process that records and uploads events, so hiding the keyboard cannot hold WAL locks and get killed as `0xdead10cc`. Keyboard-activated and per-keystroke usage events are no longer collected. / **仅主 App 记埋点**:键盘扩展不再打开统计与键盘用量 SQLite 队列;开库、记账与上传只在主 App 进行,收起键盘时不会因 WAL 锁被系统以 `0xdead10cc` 终止。不再采集「键盘激活」和逐次按键用量。
|
||||
- **iPhone Duo keyboard height**: on the unfolded inner display the keyboard keeps the iPhone key layout but uses rows sized to the system keyboard, so it is 260 pt tall instead of 281 pt and sits close to the height of Apple's keyboard. iPad, other iPhones, and the folded outer display keep their current layout. / **iPhone Duo 键盘高度**:展开后的内屏上,键盘保留 iPhone 键位,但行高按系统键盘设定,高度由 281 pt 降为 260 pt,接近系统键盘。iPad、其他 iPhone 以及合上后的外屏保持原有布局。
|
||||
- **Voice keys on wide screens**: on iPad and the unfolded iPhone Duo, the voice keyboard replaces the round buttons with one row of system-style keys along the bottom, as tall as the typing keys: space, edit, send, undo, and delete (mirrored by the left/right-hand setting). Space, edit, undo, and delete share one width and send is twice as wide and centered, filling the row edge to edge. Edit and undo are always shown and grey out when unavailable, and while dictation or AI is running the row dims instead of disappearing. The voice surface now spans the same width as the typing keys, so the logo, tabs, and top-right buttons no longer shift when switching tabs. Other iPhones and the folded outer display keep the round buttons. The voice keyboard no longer shows a globe key on any device; switch keyboards from the typing tab. / **宽屏语音按键**:在 iPad 和展开的 iPhone Duo 上,语音键盘把圆形按钮换成底部一排与打字键同高的系统风格按键:空格、编辑、发送、撤销、删除(随左右手设置镜像)。空格、编辑、撤销、删除四键等宽,发送键为其两倍宽且居中,整排铺满;编辑和撤销常驻,不可用时变灰;听写或 AI 运行时整排变暗而不是消失。语音界面与打字键区同宽,切换标签时 Logo、标签栏和右上角按钮不再左右跳动。其他 iPhone 和合上后的外屏仍使用圆形按钮。语音键盘在所有设备上都不再显示地球键,切换键盘请在打字页进行。
|
||||
- **Shorter keyboard in landscape**: on any iPhone held sideways, including the folded iPhone Duo, the keyboard is 244 pt tall instead of 281 pt, so the app above keeps more of the screen. Typing keys are 40 pt tall with the system-style half-key indent on the second row, and the voice keyboard uses the same bottom row of keys as iPad and the unfolded Duo. Portrait keyboards are unchanged. / **横屏键盘更矮**:任意 iPhone 横放时(包括合上的 iPhone Duo),键盘高度从 281 pt 降到 244 pt,上方的 App 能多露出一些内容。打字键高 40 pt,第二排按系统键盘的方式缩进半个键;语音键盘改用与 iPad、展开的 Duo 相同的底部按键排布。竖屏键盘不变。
|
||||
- **Centred mic on the voice keyboard**: on every iPhone, the iPhone Duo, and iPad, the mic now sits centred between the top bar and the bottom of the keyboard — the space above it matches the gap plus the bottom row below it — instead of sitting low with extra space piled up above. The three-line live transcript above the mic always keeps its room. / **语音键盘麦克风居中**:所有 iPhone、iPhone Duo 和 iPad 上,麦克风改为在顶栏与键盘底部之间居中:上方空间与下方间距加底排的高度相等,不再把多余高度都堆在麦克风上方、让它偏低。麦克风上方始终保留三行实时听写的空间。
|
||||
- **Clipboard skills only in the top bar**: the circular skill buttons above the mic are gone on every iPhone and iPad, including onboarding practice. Clipboard skills now appear only as text buttons in the keyboard's top bar, beside the logo; during onboarding the skill being practised shows there and the practice hint stays in the middle of the voice keyboard. / **剪贴板技能只在顶栏**:麦克风上方的圆形技能按钮在所有 iPhone 和 iPad 上都已移除,新手引导练习也不例外。剪贴板技能只以文字按钮形式出现在键盘顶栏 logo 右侧;新手引导时,正在练习的技能显示在顶栏,语音键盘中间保留练习提示。
|
||||
- **Wide-layout top spacing follows the status bar**: the extra room above the sidebar logo and the Home title is now added only when no status bar sits above the content (the unfolded iPhone Duo held sideways) instead of on every iPhone, so the portrait inner display no longer stacks it on top of its status bar. iPad is unchanged. / **宽屏顶部间距跟随状态栏**:侧边栏 logo 和首页标题上方的额外间距,改为只在内容上方没有状态栏时添加(展开的 iPhone Duo 横放时),不再按“是不是 iPhone”判断;内屏竖放时不再在状态栏之下重复留白。iPad 不变。
|
||||
- **Clipboard empty state**: an empty clipboard panel now shows an icon and a line explaining that copied text appears there. / **剪贴板空状态**:剪贴板面板为空时显示图标,并说明复制的文字会出现在这里。
|
||||
- **Privacy policy URL**: the in-app privacy policy link now points to `https://osg.osglab.com/privacy/` on the mainland-hosted OSG website instead of the GitHub Pages address, so the China App Store storefront can reach a policy served from an ICP-filed domain. The policy text now names the correct site and notes that the in-app What's New page is served from `download.osglab.com` and does not run Google Analytics. / **隐私政策链接**:App 内隐私政策改指向境内官网的 `https://osg.osglab.com/privacy/`,不再使用 GitHub Pages 地址,使中国区 App Store 能访问备案域名下的政策页面。政策文本同步更正了站点名称,并说明 App 内「更新说明」页面由 `download.osglab.com` 提供、不运行 Google Analytics。
|
||||
- **ICP number in the site footer**: the product page now shows the MIIT filing number on its own centered line below the footer, linked to `beian.miit.gov.cn`, as required for sites hosted in mainland China. Its styling lives in a `<style>` block inside `index.html` rather than `assets/site.css`, so the number stays correctly centered and sized even if only the HTML file is deployed or the stylesheet is still cached. / **官网页脚展示备案号**:产品页页脚新增独立居中的一行工信部备案号,并链接到 `beian.miit.gov.cn`,满足境内网站备案展示要求。样式写在 `index.html` 内嵌的 `<style>` 块里而非 `assets/site.css`,这样只部署 HTML 文件、或样式表仍在缓存时,备案号依然居中且字号正确。
|
||||
- **Monochrome sidebar logo**: the sidebar logo on iPad, unfolded iPhone Duo, and Mac now uses the primary text color (black in light mode, white in dark mode) instead of brand green, matching the iPhone Home logo. / **侧边栏 logo 改为单色**:iPad、展开的 iPhone Duo 和 Mac 的侧边栏 logo 改用主文字色(浅色模式黑色、深色模式白色),不再是品牌绿,与 iPhone 首页 logo 一致。
|
||||
- **One account card on Home**: Home now shows at most one account or setup card instead of a status card and an account card side by side. Missing permissions, a saved invitation, signing in for credits, choosing between credits and your own API key, running out of credits, and a credits connection problem each get one focused card above the usage stats; once everything works, that spot holds a compact credit summary (or a one-line “sign in for credits” row) instead. “Use OSG Credits” on Home goes through the same cloud-sharing consent as Settings. Voice-session status moves into a small capsule under the logo, which also names the active service when ready. / **首页只保留一张账号卡片**:首页最多只显示一张账号或设置卡片,不再同时出现状态卡和账号卡。缺少权限、已保存邀请码、登录使用积分、在积分与自己的 API Key 之间选择、积分用完、积分连接异常,各自对应一张卡片,放在用量统计上方;一切就绪后,同一位置改为紧凑的积分摘要(或一行「登录使用积分」入口)。首页的「使用 OSG 积分」与设置页一样,需要先同意云端数据共享说明。语音会话状态移到 logo 下方的小胶囊里,就绪时同时显示当前使用的服务。
|
||||
- **iPhone Duo layout**: Home picks its layout from the width it actually gets, so an unfolded iPhone Duo uses the wide dashboard even when the system reports a compact size class, and folding it switches back to the phone layout; the account card tightens on the short folded screen. / **iPhone Duo 布局**:首页按实际可用宽度选择布局,展开的 iPhone Duo 即使系统报告紧凑尺寸也使用宽屏布局,合上后切回手机布局;外屏较矮时账号卡片会更紧凑。
|
||||
|
||||
### Fixed
|
||||
- **Keyboard width tracking**: the keyboard now records its width whenever it crosses a layout threshold, not only on iPad, so the compact voice layout for narrow keyboards (Display Zoom on small iPhones, iPad Slide Over) actually applies, and the voice keys on the unfolded iPhone Duo no longer fall back to round buttons when the first layout pass reports a stale width. / **键盘宽度记录**:键盘宽度跨过任一布局门槛时都会被记录,不再只在 iPad 上记录。因此窄键盘(小屏 iPhone 开启“放大显示”、iPad 侧拉)的紧凑语音布局能真正生效;展开的 iPhone Duo 在首次布局拿到过时宽度时,语音按键也不会再退回圆形按钮。
|
||||
- **Missing globe key**: the keyboard now asks the system whether it has to draw its own globe key instead of assuming only iPad does, so the unfolded iPhone Duo — and iPhones with a Home button — get a globe key for switching keyboards. Face ID iPhones keep using the system switch below the keyboard. / **地球键缺失**:键盘改为询问系统是否需要自己提供地球键,不再假定只有 iPad 需要,因此展开的 iPhone Duo(以及带 Home 键的 iPhone)也会显示用于切换键盘的地球键;Face ID 机型仍使用键盘下方的系统切换键。
|
||||
- **Sidebar toolbar**: the wide layout's bottom status strip becomes a compact toolbar at the bottom of the sidebar — a microphone button starts or ends the voice session, the engine button opens AI service settings, and the globe button picks a translation language in place — with the session state as one word beside them; “Don't translate” no longer shows as a raw key. / **侧边栏工具栏**:宽屏布局的底部状态栏改为侧边栏底部的一排紧凑图标:麦克风按钮开启或结束语音会话,引擎按钮进入 AI 服务设置,地球按钮就地选择翻译语言,旁边用一个词显示会话状态;「不翻译」也不再显示成原始的文案 key。
|
||||
- **iPhone Duo top spacing**: the unfolded layout adds room above the sidebar logo and the Home title so they no longer crowd the rounded display corner. / **iPhone Duo 顶部间距**:展开布局在侧边栏 logo 和首页标题上方留出更多空间,不再挤在屏幕圆角下。
|
||||
- **Sidebar stays open**: the wide layout no longer offers a hide-sidebar button, so the sidebar can't get stuck hidden on iPhone Duo. / **侧边栏常驻**:宽屏布局不再提供隐藏侧边栏的按钮,iPhone Duo 上不会再出现侧边栏收起后打不开的情况。
|
||||
- **Main window crash on iOS 27**: the app no longer crashes when SwiftUI redraws the main window from a background rendering thread while the main thread is busy. / **iOS 27 主界面崩溃**:主线程繁忙、SwiftUI 在后台渲染线程重绘主窗口时,App 不再崩溃。
|
||||
|
||||
## [2.1.5] - 2026-10-25
|
||||
|
||||
### Added
|
||||
- **QR invitation poster**: the Invite button now opens a minimal poster that automatically matches light or dark appearance, uses the current App Icon, names the inviter (falling back to “OSG User”), and shows the QR code plus the short invitation code in Chinese or English. Tap the poster to show the code full screen at full brightness for face-to-face scanning; your brightness returns when you close it. You can also save the poster to Photos (add-only access), share the image, or copy the link. On Mac, the account page shows the same poster in a popover and can save it as a PNG. / **二维码邀请海报**:「邀请」按钮会打开自动适配黑白外观的极简海报,使用当前 App Icon,显示邀请人用户名(未设置时为「OSG 用户」),并以中英文展示二维码与短邀请码;点海报可全屏显示二维码并自动调到最亮,方便当面扫码,关闭后恢复原亮度;也可保存到相册(仅申请添加权限)、分享图片或复制链接。Mac 账户页以弹出面板展示同款海报,并可存为 PNG。
|
||||
- **8-character invitation codes**: every account now has a short code such as `K7M2-Q9XA`, printed on the poster, the Mac poster popover, and the invitation landing page. The code skips look-alike characters (0/O, 1/I/L), so it can be read aloud. Tap “I have a code” to type it, or paste an invitation link or a whole chat message instead; the clipboard is never read on its own. A friend who installed from the App Store therefore still gets credited. For the first 7 days after registering, and until an inviter is bound, the home card shows “Enter a code, or invite more friends for credits” with the “I have a code” button, and Invite moves to the bottom of that sheet; the window follows the server’s campaign deadline. The landing page copies the link and opens the App Store in one tap, and keeps the link-open paths that older app builds rely on. / **8 位邀请码**:每个账号都有一个形如 `K7M2-Q9XA` 的短邀请码,印在海报、Mac 海报面板与邀请落地页上;不含易混字符(0/O、1/I/L),念出来也不会输错。点「我有邀请码」即可输入,也可直接粘贴邀请链接或整段聊天消息;App 不会主动读取剪贴板。好友从 App Store 安装后仍能计入邀请。注册后 7 天内且尚未绑定邀请人时,首页卡片显示「输入邀请码,或邀请更多好友获取积分」与「我有邀请码」按钮,「邀请」入口移到弹窗底部;期限以服务器活动截止时间为准。落地页一键「复制邀请并下载」,并保留旧版本 App 依赖的链接打开方式。
|
||||
- **Native Reminders and Calendar export**: Extract To-dos and Extract Events now write directly through EventKit in the main app instead of a companion Shortcut — no Shortcut install step, and Calendar is requested with write-only access so existing events are never read. The bundled `OSGExtractTodos` / `OSGExtractEvents` Shortcut resources are removed; Save to Notes still uses its companion Shortcut. / **原生提醒事项与日历写入**:「提取待办」与「提取日程」改由主 App 通过 EventKit 直接写入,不再需要安装配套捷径;日历仅申请「仅写入」权限,不会读取你已有的日程。内置的 `OSGExtractTodos` / `OSGExtractEvents` 捷径资源已移除;「存入备忘录」仍使用配套捷径。
|
||||
- **Crash and hang reporting**: subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics — including keyboard-extension memory kills, which never surface as crashes in App Store Connect — are recorded on device and exported from Settings ▸ Diagnostics alongside Flow startup reports. App Store builds keep them on device; beta builds also upload them (see below). / **崩溃与卡顿上报**:接入 MetricKit,将崩溃、卡顿、CPU 与磁盘写入诊断(含在 App Store Connect 中从不显示为崩溃的键盘扩展内存回收)记录在本机,并可在「设置 ▸ 诊断」中与 Flow 启动报告一同导出。App Store 版本仅保留在本机,内测版本会另行上传(见下条)。
|
||||
- **Keyboard memory relief**: release the Chinese engine, the English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds, and keep sampling memory for the whole presentation instead of only during startup. Relief sheds at most once per threshold until the footprint falls back below 36 MiB, and the hard shed waits while you are mid-composition, so the keyboard never swaps surfaces out from under an unfinished syllable. / **键盘内存主动降级**:在 40 MiB 与 48 MiB 预算阈值主动释放中文引擎、英文词库与进行中的管线任务,并在整个键盘展示期间持续采样内存,而非仅在启动阶段采样。每个阈值在内存回落到 36 MiB 以下前只释放一次,且正在拼字时会推迟重度释放,不会在音节未完成时切走输入界面。
|
||||
- **Beta crash upload**: internal builds (local Debug and TestFlight) batch MetricKit crash and hang reports to `POST /v1/analytics/diagnostics` so keyboard memory shutdowns can be investigated without asking a tester to export them by hand, with a Settings toggle, per-report deduplication, retry backoff, and a circuit breaker while the backend route is unavailable. Reports reuse the analytics installation identifier and are skipped entirely when analytics are switched off; App Store builds never upload. / **内测版崩溃上传**:内测版本(本地 Debug 与 TestFlight)将 MetricKit 崩溃与卡顿报告批量发送至 `POST /v1/analytics/diagnostics`,无需测试员手动导出即可排查键盘内存回收问题;配有设置开关、逐份去重、失败退避重试,以及后端路由不可用时的熔断。报告复用统计的安装标识,关闭统计时完全不上传;App Store 版本不会上传。
|
||||
- **App Group wiring check**: gate CI on a static check that the App Group identifier and the shared Keychain access-group ordering agree across the app, the keyboard extension, the Mac target, and `project.yml`. / **App Group 配置校验**:在 CI 中静态校验 App Group 标识符与共享 Keychain 访问组顺序在主 App、键盘扩展、Mac 端及 `project.yml` 之间保持一致。
|
||||
|
||||
### Changed
|
||||
- **Dictation progress on the microphone**: while a dictation is being recognized and polished, the microphone capsule fills from left to right in the brand colour instead of showing a spinner. The fill steps forward when recognition finishes, slows as it nears the end, and completes only when the text is inserted; the polish estimate adapts to how long polishing usually takes on this device. / **麦克风上的听写进度**:听写识别与润色期间,麦克风胶囊改为从左到右填充品牌色,取代原来的转圈。识别完成时进度向前跳一段,越接近结束走得越慢,只有文字真正插入时才填满;润色耗时的预估会按这台设备的实际情况自动校准。
|
||||
- **Messaging shift to AI voice keyboard / voice double / agentic skills**: README and the product page now lead with “Say it. It’s typed. Speaks like you. Acts for you.” — the personalization story (learned style, three-tone replies) moves ahead of typing, clipboard skills are framed as agentic actions, and typing becomes the fallback capability. / **宣传定位转向 AI 语音键盘 / 嘴替 / Agentic 技能**:README 与产品页改用「开口即文字。会说你的话,会办你的事。」定位;个人化卖点(专属风格、三语气回复)提前,剪贴板技能表述升级为 Agentic 行动,打字退为兜底能力。
|
||||
- **Product page redesign**: rebuild the GitHub Pages home as a dark product stage with the new Icon Composer app icon, the three-device hero visual, and real on-device videos and UI cards replacing hand-drawn mockups; add 2.1 selling points (personal style, three-tone replies, monthly usage calendar, EventKit skills export, guided trials and credit rewards), a TestFlight link, and self-hosted inline SVG icons; bump page references to 2.1.5. / **产品页重设计**:GitHub Pages 主页改为深色产品舞台,启用 Icon Composer 新图标与三端同框主视觉,以实机视频与真实界面卡片取代手绘示意;补齐 2.1 卖点(专属风格、三语气个性回复、月度用量日历、EventKit 技能写入、引导体验与积分奖励),新增 TestFlight 入口,图标改为自托管内联 SVG;页面版本号更新至 2.1.5。
|
||||
- **Host-only analytics recording**: the keyboard extension no longer opens the analytics or keyboard-usage SQLite queues. The host app is the only process that records and uploads events, so hiding the keyboard cannot hold WAL locks and get killed as `0xdead10cc`. Keyboard-activated and per-keystroke usage events are no longer collected. / **仅主 App 记埋点**:键盘扩展不再打开统计与键盘用量 SQLite 队列;开库、记账与上传只在主 App 进行,收起键盘时不会因 WAL 锁被系统以 `0xdead10cc` 终止。不再采集「键盘激活」和逐次按键用量。
|
||||
- **Lighter keyboard redraws while dictating**: the keyboard now redraws only the parts whose data changed. The microphone level (5 times a second) updates just the waveform bars, and live recognized text updates just the status line, instead of rebuilding the whole voice panel each time. / **听写时键盘重绘更轻**:键盘现在只重绘数据有变化的部分。麦克风音量(每秒 5 次)只刷新波形条,实时识别文字只刷新状态行,不再每次都重建整个语音面板。
|
||||
|
||||
### Fixed
|
||||
- **Keyboard killed after hiding on pinyin**: hiding the keyboard while the pinyin/English typing surface was showing kept librime's user dictionary open, and its LevelDB `LOCK` file in the shared App Group got the suspended extension killed as `0xdead10cc` (about 30 kills in 3 days on one test device). The typing engine is now closed on every hide and reopened on the next show, which adds about 30–50 ms only when a still-running keyboard is reopened. / **拼音界面收起后键盘被系统终止**:在拼音/英文打字界面收起键盘时,librime 用户词库仍保持打开,其位于共享 App Group 的 LevelDB `LOCK` 文件导致挂起的扩展被系统以 `0xdead10cc` 终止(一台测试机 3 天约 30 次)。现在每次收起都会关闭打字引擎、下次弹出时重新打开,仅在复用仍存活的键盘进程时多约 30–50 毫秒。
|
||||
- **Voice session failing after a system audio restart**: when iOS restarted its media service while OSG was in the background, starting the voice session on return used up all three tries in about 1 second, before the system had recovered (about 3 seconds), and showed “needs recovery”. Automatic tries are now spread across 5 seconds (immediately, then at 0.5, 1.5 and 3 seconds), retry at once when the system reports the media service is back, and reconfigure the audio session instead of trusting state from before the restart. Tapping Retry uses the same schedule. / **系统音频重启后语音会话启动失败**:OSG 在后台期间若 iOS 重启了媒体服务,回到前台时启动语音会话会在约 1 秒内用完全部 3 次尝试,而系统约 3 秒后才恢复,于是显示「后台会话需要恢复」。现在自动尝试分布在 5 秒内(立即、0.5 秒、1.5 秒、3 秒),系统通知媒体服务已恢复时立即重试,并重新配置音频会话,不再沿用重启前的状态。手动点「重试」也按同样节奏进行。
|
||||
- **Misleading edit hint after insertion**: stop showing “Hold to edit your last input” for 10 seconds after every insertion. Holding the microphone asks AI; editing the last input uses the pencil button beside it. / **插入后的误导性编辑提示**:每次插入后不再显示 10 秒的「长按编辑上一条」。长按麦克风是向 AI 提问,编辑上一条请使用旁边的铅笔按钮。
|
||||
- **Saving invitation posters**: declare add-only Photos access in the built app and preflight the privacy description before calling PhotoKit, preventing Save Image from terminating the app when the generated project configuration is stale. / **保存邀请海报**:在 App 构建配置中声明“仅添加照片”权限,并在调用 PhotoKit 前检查权限说明,避免生成的工程配置过期时点击“保存图片”导致 App 被系统终止。
|
||||
- **Invitation redemption retrying forever**: an invitation the server permanently rejected (unknown code, self-referral, already bound, or binding window expired) stayed stored and was retried on every sign-in. These rejections now show a specific message and drop the stored code; network failures still retry. / **邀请兑换无限重试**:服务器明确拒绝的邀请(邀请码无效、邀请自己、已绑定或超出绑定期限)会一直保留并在每次登录时重试。现在这些情况会给出具体提示并清除暂存的邀请码;网络失败仍会重试。
|
||||
- **Faster managed dictation feedback**: begin the managed Volcengine streaming connection while the microphone touch primes audio, reuse it when recording starts, and keep the live transcript automatically scrolled to the newest recognition text. The early connection is reused only while it is fresh (under 10 seconds) and still open; otherwise it is closed and a new one opens, so an unused early connection can no longer make the next dictation fail. / **托管听写反馈提速**:在麦克风触摸预热音频时同步建立托管火山流式连接,录音开始后直接复用,并让实时转写自动滚动到最新识别文字。提前建立的连接仅在新鲜(10 秒内)且仍可用时复用,否则关闭并重新建立,未被使用的提前连接不会再导致下一次听写失败。
|
||||
- **Recording kept when live recognition fails**: a streaming recognition error while you were still speaking ended the dictation immediately and discarded the audio. Recording now continues, and when you finish, the recorded audio is retried with batch recognition before any error is shown. After a cancelled or failed dictation, that batch retry also stopped working for the rest of the session; it now works again from the next dictation. / **实时识别失败时保留录音**:说话过程中流式识别一旦出错,本次听写会立即结束并丢弃录音。现在会继续录音,说完后先用批量识别重试已录下的音频,仍失败才提示错误。此前一次听写被取消或失败后,本次会话内的批量重试也会失效;现在从下一次听写起恢复正常。
|
||||
- **Stalled cloud ASR losing the recording**: when the streaming recognizer went quiet and the result wait expired, the utterance ended as "no speech" even though the audio was captured. A stalled recognizer now counts as a failure, so the batch ASR fallback retries the recorded audio before giving up. / **云端识别卡住时丢失录音**:流式识别无响应、等待结果超时后,即便录音已采集,本次语音也会直接报「未检测到语音」。现在识别卡住按失败处理,会先用批量识别重试已录下的音频再放弃。
|
||||
- **Speak as Me output shape**: the skill could answer with raw JSON variants or echo the personal-style XML tags into the result. The prompt now requires one ready-to-send plain-text rewrite, and length follows your natural voice instead of a mechanical ±20% cap. / **「像我所说」输出形态**:该技能可能返回原始 JSON 候选,或把个人风格的 XML 标记带进结果。现在提示词要求只输出一段可直接发送的文本,长度改为跟随你的自然表达节奏,不再机械限制 ±20%。
|
||||
- **Clipboard history reverting deletes**: history is one shared blob, and the main App kept an in-memory copy that only refreshed when a screen appeared — so a delete made there could write back entries the keyboard had just captured or removed, and a clear done in the keyboard could be undone entirely. Both processes now announce their writes to each other, and the main App re-reads history whenever it returns to the foreground. / **剪贴板历史撤销删除**:历史记录是整块共享数据,而主 App 仅在页面出现时刷新内存副本——因此在主 App 中删除一条,可能把键盘刚采集或刚删除的条目重新写回,在键盘里执行的清空也可能被整体撤销。现在两端写入时会互相通知,主 App 每次回到前台也会重新读取历史。
|
||||
- **Keyboard crash loop on memory relief**: recording a memory milestone could request relief while relief was already running, and the two sides called each other until the keyboard extension exhausted its stack (SIGSEGV grouped under `KeyboardViewController.viewDidLoad`). Reentrancy guards on both the controller and the telemetry side break the cycle. / **键盘内存降级递归崩溃**:记录内存里程碑时可能再次请求降级,两侧互相回调直至扩展栈溢出(崩溃聚合在 `KeyboardViewController.viewDidLoad`)。控制器与遥测两侧均加重入保护,打断循环。
|
||||
@@ -20,17 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Managed ASR connect crash**: `URLSessionWebSocketTask.sendPing` can invoke its completion twice when the task is cancelled mid-ping, so opening a managed Volcengine session could trap on the second continuation resume (SIGTRAP). The handler now passes through a once-only gate. / **托管语音识别建连崩溃**:`URLSessionWebSocketTask.sendPing` 在 ping 在途时被取消会回调两次,打开托管火山引擎会话时第二次恢复 continuation 会触发 SIGTRAP 断言。回调现经一次性闸门过滤。
|
||||
- **Auto-reply storms from Universal Clipboard**: copying on a nearby Mac re-announces one copy under several pasteboard changeCounts, and each re-announcement re-armed auto reply/translate and the auto-reply guide — including on every later keyboard open, because re-ingesting the same text kept refreshing its 30-second hint window. A capture whose text is identical to the newest history entry is now treated as the same logical copy (bookkeeping only, no re-analysis), and auto actions additionally dedupe on a content fingerprint within a 30-second window. The same text fires once; copying something else and then re-copying the text still triggers. / **跨端剪贴板引发自动回复风暴**:在附近 Mac 上复制一次会被系统以多个 changeCount 重复宣告,且每次宣告都会重新武装自动回复/翻译与自动回复引导——由于同文本被重复入库并不断刷新 30 秒提示窗口,之后每次打开键盘也会再触发。现在,与最新历史条目文本相同的采集会被视为同一次复制(仅更新记账,不重新分析),自动动作再叠加内容指纹 + 30 秒窗口去重。同一文本只触发一次;复制其他内容后再重新复制该文本仍可触发。
|
||||
- **Style card captions**: resolve each built-in style’s short description through the in-app language override, so switching App Language updates the caption instead of showing a localization key or the system language. / **风格卡片说明**:内置风格的说明小字改为走应用内语言设置;切换 App 语言后说明会跟着变,而不再显示本地化 key 或跟随系统语言。
|
||||
|
||||
## [2.2.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
- **Native Reminders and Calendar export**: Extract To-dos and Extract Events now write directly through EventKit in the main app instead of a companion Shortcut — no Shortcut install step, and Calendar is requested with write-only access so existing events are never read. The bundled `OSGExtractTodos` / `OSGExtractEvents` Shortcut resources are removed; Save to Notes still uses its companion Shortcut. / **原生提醒事项与日历写入**:「提取待办」与「提取日程」改由主 App 通过 EventKit 直接写入,不再需要安装配套捷径;日历仅申请「仅写入」权限,不会读取你已有的日程。内置的 `OSGExtractTodos` / `OSGExtractEvents` 捷径资源已移除;「存入备忘录」仍使用配套捷径。
|
||||
- **Crash and hang reporting**: subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics — including keyboard-extension memory kills, which never surface as crashes in App Store Connect — are recorded on device and exported from Settings ▸ Diagnostics alongside Flow startup reports. App Store builds keep them on device; beta builds also upload them (see below). / **崩溃与卡顿上报**:接入 MetricKit,将崩溃、卡顿、CPU 与磁盘写入诊断(含在 App Store Connect 中从不显示为崩溃的键盘扩展内存回收)记录在本机,并可在「设置 ▸ 诊断」中与 Flow 启动报告一同导出。App Store 版本仅保留在本机,内测版本会另行上传(见下条)。
|
||||
- **Keyboard memory relief**: release the Chinese engine, the English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds, and keep sampling memory for the whole presentation instead of only during startup. Relief sheds at most once per threshold until the footprint falls back below 36 MiB, and the hard shed waits while you are mid-composition, so the keyboard never swaps surfaces out from under an unfinished syllable. / **键盘内存主动降级**:在 40 MiB 与 48 MiB 预算阈值主动释放中文引擎、英文词库与进行中的管线任务,并在整个键盘展示期间持续采样内存,而非仅在启动阶段采样。每个阈值在内存回落到 36 MiB 以下前只释放一次,且正在拼字时会推迟重度释放,不会在音节未完成时切走输入界面。
|
||||
- **Beta crash upload**: internal builds (local Debug and TestFlight) batch MetricKit crash and hang reports to `POST /v1/analytics/diagnostics` so keyboard memory shutdowns can be investigated without asking a tester to export them by hand, with a Settings toggle, per-report deduplication, retry backoff, and a circuit breaker while the backend route is unavailable. Reports reuse the analytics installation identifier and are skipped entirely when analytics are switched off; App Store builds never upload. / **内测版崩溃上传**:内测版本(本地 Debug 与 TestFlight)将 MetricKit 崩溃与卡顿报告批量发送至 `POST /v1/analytics/diagnostics`,无需测试员手动导出即可排查键盘内存回收问题;配有设置开关、逐份去重、失败退避重试,以及后端路由不可用时的熔断。报告复用统计的安装标识,关闭统计时完全不上传;App Store 版本不会上传。
|
||||
- **App Group wiring check**: gate CI on a static check that the App Group identifier and the shared Keychain access-group ordering agree across the app, the keyboard extension, the Mac target, and `project.yml`. / **App Group 配置校验**:在 CI 中静态校验 App Group 标识符与共享 Keychain 访问组顺序在主 App、键盘扩展、Mac 端及 `project.yml` 之间保持一致。
|
||||
|
||||
### Fixed
|
||||
- **Personal reply style keeps learning**: regenerating used only the newest ~2,500 characters, so new dictation pushed earlier learning out instead of adding to it. Regeneration is now incremental: it merges what the style already learned (kept on this device only) with dictation and reply choices since the last generation, unlocks after 500 new characters, and the card shows the cumulative character count plus progress since the last generation. Traits that stop getting new support fade out over a few rounds. A style without saved learning (older builds or another device) is relearned in full and replaced. / **个人回复风格持续学习**:此前重新生成只使用最新约 2,500 字,新听写会把早期学习挤掉而非叠加。现在改为增量重新生成:把该风格已学到的特征(仅保存在本机)与上次生成后的新听写和回复选择合并,新增满 500 字即可重新生成;卡片显示累计学习字数与自上次生成后的新增进度。长期没有新证据支持的特征会在几轮后自然淘汰。没有本机学习记录的风格(旧版本或其他设备生成)会全量重新学习并替换。
|
||||
- **App Group error screen**: resolve localized text without the App Group so the screen shown when that container is missing renders its message instead of crashing on launch — the one situation it exists to handle. / **App Group 错误页**:本地化文案不再依赖 App Group,容器缺失时该页面可正常显示提示,而不是在启动时崩溃——这正是它存在的场景。
|
||||
- **Dictation preview and Mac recording crashes**: install audio taps with the input node's live format and resample adaptively through one shared converter, so changing the audio route mid-recording no longer raises an uncatchable format-mismatch exception. / **听写预览与 Mac 录音崩溃**:音频采集改用输入节点的实时格式,并通过统一的自适应重采样器转换,录音过程中切换音频线路不再触发不可捕获的格式不匹配异常。
|
||||
- **Degenerate audio routes**: clamp the resampled frame count before converting it, so a hardware rate near zero drops one frame instead of trapping on the realtime audio thread. / **异常音频线路**:重采样帧数在转换前先行钳制,采样率接近零时仅丢弃一帧,而不会在实时音频线程上触发陷阱。
|
||||
|
||||
@@ -108,6 +108,8 @@
|
||||
<string>OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>OSGKeyboard saves your invitation poster to Photos only when you choose Save Image.</string>
|
||||
<key>NSRemindersFullAccessUsageDescription</key>
|
||||
<string>OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
|
||||
@@ -10,14 +10,6 @@ import UIKit
|
||||
struct OSGKeyboardApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
|
||||
|
||||
/// App-local light / dark preference (Settings ▸ Preferences ▸ Appearance).
|
||||
@AppStorage(AppearancePreference.storageKey)
|
||||
private var appearanceRaw = AppearancePreference.system.rawValue
|
||||
|
||||
private var appearance: AppearancePreference {
|
||||
AppearancePreference.fromStored(appearanceRaw)
|
||||
}
|
||||
|
||||
init() {
|
||||
MaterialIconsFont.registerIfNeeded()
|
||||
// Keep system alert and confirmation actions neutral instead of
|
||||
@@ -39,7 +31,25 @@ struct OSGKeyboardApp: App {
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
// Keep the App free of dynamic state. SwiftUI's async renderer may
|
||||
// re-run a dirty WindowGroup closure off the main thread, which trips
|
||||
// Swift 6's MainActor check; a View body has no such dynamic check.
|
||||
WindowGroup {
|
||||
AppSceneRoot()
|
||||
}
|
||||
}
|
||||
|
||||
/// Window root; owns the appearance state the App body must not hold.
|
||||
private struct AppSceneRoot: View {
|
||||
/// App-local light / dark preference (Settings ▸ Preferences ▸ Appearance).
|
||||
@AppStorage(AppearancePreference.storageKey)
|
||||
private var appearanceRaw = AppearancePreference.system.rawValue
|
||||
|
||||
private var appearance: AppearancePreference {
|
||||
AppearancePreference.fromStored(appearanceRaw)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
if OnboardingExperienceView.isPreviewEnabled {
|
||||
OOBEPreviewHarness()
|
||||
@@ -47,7 +57,9 @@ struct OSGKeyboardApp: App {
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
|
||||
// Approach A: Notes-like host only; real keyboard extension overlays it.
|
||||
// Also used by `--keyboard-appear-stress=` (pass both flags).
|
||||
Self.makeWhatsNewHostView()
|
||||
OSGKeyboardApp.makeWhatsNewHostView()
|
||||
} else if let conceptScene = ConceptFilmDemoView.requestedScene {
|
||||
ConceptFilmDemoView(scene: conceptScene)
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--edit-demo") {
|
||||
EditDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
|
||||
|
||||
@@ -112,12 +112,16 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
MainActor.assumeIsolated {
|
||||
for item in items {
|
||||
// `sourceApplication` is only non-nil when the caller belongs to
|
||||
// the same Apple Developer Team (our own keyboard extension) —
|
||||
// exactly what the host-return whitelist relies on. Record it
|
||||
// ONLY for the `startflow` handoff: overwriting it for every
|
||||
// deep link (e.g. `osgkeyboard://settings`) could point a later
|
||||
// cold-start "return to host" at the wrong app.
|
||||
if let source = item.source, item.url.host == "startflow" {
|
||||
// the same Apple Developer Team, so for the keyboard handoff it is
|
||||
// our own extension, not the app the user was typing in. The
|
||||
// keyboard publishes the real host before opening `startflow`;
|
||||
// never overwrite that with an OSG bundle id. Record it ONLY for
|
||||
// the `startflow` handoff: overwriting it for every deep link
|
||||
// (e.g. `osgkeyboard://settings`) could point a later cold-start
|
||||
// "return to host" at the wrong app.
|
||||
if let source = item.source,
|
||||
item.url.host == "startflow",
|
||||
!HostAppURLRegistry.isOwnBundleId(source) {
|
||||
FlowSessionBridge.setPendingHostBundleId(source)
|
||||
}
|
||||
AppOpenURLRouter.shared.route(item.url)
|
||||
|
||||
@@ -99,7 +99,7 @@ struct FlowPiPLossReportGate: Equatable, Sendable {
|
||||
}
|
||||
|
||||
enum FlowPiPRecoveryPolicy {
|
||||
static let maxAttempts = 3
|
||||
static let maxAttempts = 4
|
||||
static let totalBudget: TimeInterval = 5
|
||||
|
||||
static func reconciliationDecision(
|
||||
@@ -125,11 +125,16 @@ enum FlowPiPRecoveryPolicy {
|
||||
&& !taskIsCancelled
|
||||
}
|
||||
|
||||
static func retryDelay(beforeAttempt attempt: Int) -> TimeInterval {
|
||||
/// Earliest start of each attempt, measured from the start of recovery.
|
||||
/// Audio activation failures return almost instantly, so gaps measured
|
||||
/// after each failure would spend every attempt within ~1 s, while a reset
|
||||
/// media server needs ~3 s before activation succeeds again.
|
||||
static func attemptOffset(beforeAttempt attempt: Int) -> TimeInterval {
|
||||
switch attempt {
|
||||
case 1: return 0
|
||||
case 2: return 0.25
|
||||
default: return 0.6
|
||||
case 2: return 0.5
|
||||
case 3: return 1.5
|
||||
default: return 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1167,6 +1172,20 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleeps until the next scheduled PiP attempt, waking early once the
|
||||
/// system media server finishes a reset so the retry lands right away.
|
||||
private func waitForNextPiPAttempt(seconds: TimeInterval) async {
|
||||
let wakeAt = Date().addingTimeInterval(seconds)
|
||||
let resetCount = FlowAudioSessionCoordinator.shared.mediaServicesResetCount
|
||||
while Date() < wakeAt, !Task.isCancelled {
|
||||
if FlowAudioSessionCoordinator.shared.mediaServicesResetCount != resetCount {
|
||||
traceState("pipRecovery.wokeOnMediaServicesReset")
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func startSessionAsync(
|
||||
duration: TimeInterval?,
|
||||
trigger: FlowPiPActivationTrigger,
|
||||
@@ -1207,7 +1226,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
|
||||
let attemptsStartedAt = Date()
|
||||
let deadline = attemptsStartedAt.addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
|
||||
var lastFailure: FlowPiPStartFailure = .timedOut
|
||||
var attemptsPerformed = 0
|
||||
|
||||
@@ -1234,11 +1254,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let delay = FlowPiPRecoveryPolicy.retryDelay(beforeAttempt: attempt)
|
||||
if delay > 0 {
|
||||
try? await Task.sleep(
|
||||
nanoseconds: UInt64(delay * 1_000_000_000)
|
||||
)
|
||||
let wait = FlowPiPRecoveryPolicy.attemptOffset(beforeAttempt: attempt)
|
||||
- Date().timeIntervalSince(attemptsStartedAt)
|
||||
if wait > 0 {
|
||||
await waitForNextPiPAttempt(seconds: wait)
|
||||
}
|
||||
guard FlowPiPRecoveryPolicy.canContinue(
|
||||
operation: operation,
|
||||
@@ -2109,6 +2128,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
!hasUnacknowledgedTerminalResult() else {
|
||||
return
|
||||
}
|
||||
bindSessionASRIfNeeded()
|
||||
if let cloudASR = sessionASR as? CloudASRService {
|
||||
// Same locale source as `beginUtterance`, so the prime is adoptable.
|
||||
cloudASR.primeUtteranceStreaming(
|
||||
locale: SpeechLocaleResolver.resolve(store.localeId)
|
||||
)
|
||||
}
|
||||
startAudioPrime(id: command.utteranceId, origin: "micTouch")
|
||||
}
|
||||
|
||||
@@ -2171,6 +2197,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
!isUtteranceProcessing else {
|
||||
return
|
||||
}
|
||||
(sessionASR as? CloudASRService)?.cancelPrimedUtteranceStreaming()
|
||||
audioPrimeCancellationRequested = true
|
||||
// `capture.start()` is not cancellation-safe. Never stop it mid-start;
|
||||
// the completion waiter performs cleanup unless a real utterance adopts
|
||||
@@ -2613,11 +2640,14 @@ final class FlowSessionManager: ObservableObject {
|
||||
]
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first(where: { !$0.isEmpty })
|
||||
// Without a partial, keep recording: capture is independent
|
||||
// of ASR, and finalize retries the full PCM through batch
|
||||
// ASR (`recognitionFailed`) before reporting the error.
|
||||
if let recovery {
|
||||
manager.lastFinal = recovery
|
||||
manager.debug("asr error recovered via partial len=\(recovery.count)")
|
||||
} else if manager.isUtteranceRecording {
|
||||
manager.failUtterance(message: message, kind: .asrFailed)
|
||||
manager.debug("asr error while recording — deferring to batch fallback")
|
||||
}
|
||||
case .cancelled:
|
||||
// A provider-side cancellation while the mic is still open
|
||||
@@ -2935,8 +2965,16 @@ final class FlowSessionManager: ObservableObject {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
|
||||
// The recognizer never reported back within the wait: treat it as a
|
||||
// failure so batch ASR can still recover the captured audio.
|
||||
let asrStalled = lastFinal.isEmpty
|
||||
&& asrCompletedGeneration != finalizeGeneration
|
||||
&& !Task.isCancelled
|
||||
if lastFinal.isEmpty {
|
||||
FlowDiagnostics.log("ASR wait elapsed — cancelling ASR task and using best available transcript")
|
||||
FlowDiagnostics.log(
|
||||
"ASR wait elapsed — cancelling ASR task and using best available transcript "
|
||||
+ "stalled=\(asrStalled ? 1 : 0)"
|
||||
)
|
||||
asrTask?.cancel()
|
||||
Task { await chunkedPipeline?.cancel() }
|
||||
}
|
||||
@@ -2973,11 +3011,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
let wantsBatchFallback = UtteranceBatchFallbackPolicy.shouldRunBatchFallback(
|
||||
stitchedFinal: lastFinal,
|
||||
partialSnapshot: bestPartialSnapshot,
|
||||
recognitionFailed: asrFailureMessage != nil
|
||||
recognitionFailed: asrFailureMessage != nil,
|
||||
recognitionStalled: asrStalled
|
||||
)
|
||||
FlowTrace.pipeline(
|
||||
"batchFallback.decision",
|
||||
"wanted=\(wantsBatchFallback ? 1 : 0) pcmSamples=\(utterancePCMSamples.count) "
|
||||
"wanted=\(wantsBatchFallback ? 1 : 0) stalled=\(asrStalled ? 1 : 0) "
|
||||
+ "pcmSamples=\(utterancePCMSamples.count) "
|
||||
+ "pcmRms=\(FlowTrace.rms(utterancePCMSamples)) "
|
||||
+ "stitchedLen=\(lastFinal.count) partialLen=\(bestPartialSnapshot.count) "
|
||||
+ "resolvedLen=\(text.count)"
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Opens a whitelisted host-app URL after a cold-start Flow handoff.
|
||||
//
|
||||
// No `canOpenURL` pre-check: iOS caps `LSApplicationQueriesSchemes` at 50
|
||||
// entries (already spent) and the whitelist is far larger. `open` needs no
|
||||
// declaration and reports failure through its completion instead.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
@@ -13,12 +17,11 @@ enum HostReturnService {
|
||||
let bundleId = FlowSessionBridge.pendingHostBundleId()
|
||||
guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId),
|
||||
let url = entry.returnURL else {
|
||||
return false
|
||||
}
|
||||
guard UIApplication.shared.canOpenURL(url) else {
|
||||
logReturn(bundleId: bundleId, outcome: "no-entry")
|
||||
return false
|
||||
}
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
logReturn(bundleId: bundleId, outcome: success ? "opened" : "open-failed")
|
||||
if success {
|
||||
FlowSessionBridge.clearPendingHostBundleId()
|
||||
}
|
||||
@@ -28,7 +31,7 @@ enum HostReturnService {
|
||||
|
||||
@MainActor
|
||||
static func openHost(entry: HostAppEntry) -> Bool {
|
||||
guard let url = entry.returnURL, UIApplication.shared.canOpenURL(url) else {
|
||||
guard let url = entry.returnURL else {
|
||||
return false
|
||||
}
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
@@ -43,4 +46,10 @@ enum HostReturnService {
|
||||
static func pendingHostEntry() -> HostAppEntry? {
|
||||
HostAppURLRegistry.lookup(bundleId: FlowSessionBridge.pendingHostBundleId())
|
||||
}
|
||||
|
||||
private static func logReturn(bundleId: String?, outcome: String) {
|
||||
#if DEBUG
|
||||
HostBundleProbeLog.append(event: "return", bundleId: bundleId, detail: outcome)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import Foundation
|
||||
enum LegalLinks {
|
||||
static let repositoryURL = URL(string: "https://github.com/hkgood/OSGKeyboard")!
|
||||
|
||||
/// Public privacy policy (GitHub Pages). Also bundled in-app as PrivacyPolicy.html.
|
||||
/// Public privacy policy. Served from the mainland-hosted website so the
|
||||
/// China storefront (ICP filing) can reach a备案 domain; also bundled
|
||||
/// in-app as PrivacyPolicy.html.
|
||||
static var privacyPolicyURL: URL? {
|
||||
URL(string: "https://hkgood.github.io/OSGKeyboard/privacy/")
|
||||
URL(string: "https://osg.osglab.com/privacy/")
|
||||
}
|
||||
|
||||
/// Support / feedback (GitHub Issues).
|
||||
|
||||
@@ -12,7 +12,7 @@ protocol PublicContentHTTPTransport: Sendable {
|
||||
|
||||
final class URLSessionPublicContentHTTPTransport:
|
||||
PublicContentHTTPTransport,
|
||||
@unchecked Sendable {
|
||||
Sendable {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
|
||||
@@ -30,9 +30,16 @@ enum AppL10n {
|
||||
/// screen that exists so a provisioning mistake does *not* become a crash
|
||||
/// loop. `.auto` follows the system language, which is the right answer
|
||||
/// when no stored preference is reachable anyway.
|
||||
///
|
||||
/// Reads the persisted key directly rather than `ProviderConfig.shared`
|
||||
/// (main-actor isolated) because localized strings are also resolved off
|
||||
/// the main actor. `ProviderConfig` writes this key synchronously in its
|
||||
/// `uiLanguage` didSet, so both paths observe the same value.
|
||||
private static var resolvedUILanguage: AppUILanguage {
|
||||
guard AppGroup.isAvailable else { return .auto }
|
||||
return ProviderConfig.shared.uiLanguage
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return .auto }
|
||||
return AppUILanguage.fromStored(
|
||||
defaults.string(forKey: AppGroupConfiguration.Keys.uiLanguage)
|
||||
)
|
||||
}
|
||||
|
||||
static func format(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// ScreenBrightnessBoost.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Reference-counted full-brightness hold for content another device scans,
|
||||
// such as the invitation QR code. iOS keeps an app-set brightness after the
|
||||
// app leaves the foreground, so the hold restores the user's level while the
|
||||
// app is in the background and reapplies it on return.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
protocol ScreenBrightnessControlling: AnyObject {
|
||||
var brightness: CGFloat { get set }
|
||||
}
|
||||
|
||||
extension UIScreen: ScreenBrightnessControlling {}
|
||||
|
||||
@MainActor
|
||||
final class ScreenBrightnessBoost {
|
||||
static let shared = ScreenBrightnessBoost(screenProvider: foregroundScreen)
|
||||
|
||||
private let screenProvider: @MainActor () -> ScreenBrightnessControlling?
|
||||
private var holdCount = 0
|
||||
private var isInForeground = true
|
||||
/// Set only while boosted, so the user's level is captured exactly once.
|
||||
private var boost: (screen: ScreenBrightnessControlling, originalBrightness: CGFloat)?
|
||||
|
||||
init(screenProvider: @escaping @MainActor () -> ScreenBrightnessControlling?) {
|
||||
self.screenProvider = screenProvider
|
||||
}
|
||||
|
||||
func acquire() {
|
||||
holdCount += 1
|
||||
update()
|
||||
}
|
||||
|
||||
func release() {
|
||||
guard holdCount > 0 else { return }
|
||||
holdCount -= 1
|
||||
update()
|
||||
}
|
||||
|
||||
func setInForeground(_ inForeground: Bool) {
|
||||
isInForeground = inForeground
|
||||
update()
|
||||
}
|
||||
|
||||
private func update() {
|
||||
let wantsBoost = holdCount > 0 && isInForeground
|
||||
if wantsBoost, boost == nil, let screen = screenProvider() {
|
||||
boost = (screen, screen.brightness)
|
||||
screen.brightness = 1
|
||||
} else if !wantsBoost, let boost {
|
||||
boost.screen.brightness = boost.originalBrightness
|
||||
self.boost = nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func foregroundScreen() -> ScreenBrightnessControlling? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first { $0.activationState == .foregroundActive }?
|
||||
.screen
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Holds full screen brightness while this view is on screen.
|
||||
func boostsScreenBrightness() -> some View {
|
||||
modifier(ScreenBrightnessBoostModifier())
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScreenBrightnessBoostModifier: ViewModifier {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear { ScreenBrightnessBoost.shared.acquire() }
|
||||
.onDisappear { ScreenBrightnessBoost.shared.release() }
|
||||
// `.inactive` is skipped on purpose: Control Center makes the scene
|
||||
// inactive, and restoring there would fight a manual adjustment.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
ScreenBrightnessBoost.shared.setInForeground(phase != .background)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import SwiftUI
|
||||
|
||||
struct AIClipboardSkillLayoutDemoView: View {
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
@StateObject private var state = KeyboardState()
|
||||
@State private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
@State private var count: Int = Self.initialCount
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ struct AIKeyboardDemoView: View {
|
||||
}
|
||||
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
@StateObject private var state = KeyboardState()
|
||||
@State private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
|
||||
@State private var scene: Scene = .settings
|
||||
|
||||
@@ -80,19 +80,34 @@ struct ReferralCode: Codable, Equatable, Sendable {
|
||||
let inviteURL: URL
|
||||
let campaignID: String?
|
||||
let createdAt: Date
|
||||
/// 8-character alias friends can type; nil until the server assigns one.
|
||||
let shortCode: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code
|
||||
case inviteURL = "inviteUrl"
|
||||
case campaignID = "campaignId"
|
||||
case createdAt
|
||||
case shortCode
|
||||
}
|
||||
|
||||
init(code: String, inviteURL: URL, campaignID: String?, createdAt: Date) {
|
||||
init(
|
||||
code: String,
|
||||
inviteURL: URL,
|
||||
campaignID: String?,
|
||||
createdAt: Date,
|
||||
shortCode: String? = nil
|
||||
) {
|
||||
self.code = code
|
||||
self.inviteURL = inviteURL
|
||||
self.campaignID = campaignID
|
||||
self.createdAt = createdAt
|
||||
self.shortCode = shortCode.flatMap(ReferralShortCode.normalize)
|
||||
}
|
||||
|
||||
/// `XXXX-XXXX` form for posters and share sheets.
|
||||
var displayShortCode: String? {
|
||||
shortCode.map(ReferralShortCode.display)
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -115,6 +130,10 @@ struct ReferralCode: Codable, Equatable, Sendable {
|
||||
self.inviteURL = inviteURL
|
||||
campaignID = try container.decodeIfPresent(String.self, forKey: .campaignID)
|
||||
self.createdAt = createdAt
|
||||
// A malformed alias only hides the short code; the link still works.
|
||||
shortCode = (try? container.decodeIfPresent(String.self, forKey: .shortCode))
|
||||
.flatMap { $0 }
|
||||
.flatMap(ReferralShortCode.normalize)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -123,9 +142,10 @@ struct ReferralCode: Codable, Equatable, Sendable {
|
||||
try container.encode(inviteURL, forKey: .inviteURL)
|
||||
try container.encodeIfPresent(campaignID, forKey: .campaignID)
|
||||
try container.encode(Self.string(from: createdAt), forKey: .createdAt)
|
||||
try container.encodeIfPresent(shortCode, forKey: .shortCode)
|
||||
}
|
||||
|
||||
private static func date(from value: String) -> Date? {
|
||||
static func date(from value: String) -> Date? {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let standard = ISO8601DateFormatter()
|
||||
@@ -133,7 +153,7 @@ struct ReferralCode: Codable, Equatable, Sendable {
|
||||
return fractional.date(from: value) ?? standard.date(from: value)
|
||||
}
|
||||
|
||||
private static func string(from date: Date) -> String {
|
||||
static func string(from date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.string(from: date)
|
||||
@@ -214,6 +234,40 @@ enum ReferralJSONValue: Codable, Equatable, Sendable {
|
||||
struct ReferralProfile: Codable, Equatable, Sendable {
|
||||
let code: ReferralCode
|
||||
let binding: ReferralBinding?
|
||||
/// Last moment this account may still bind an inviter. The server omits it
|
||||
/// once bound or when no campaign is active; older servers never send it.
|
||||
let bindingDeadline: Date?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code
|
||||
case binding
|
||||
case bindingDeadline
|
||||
}
|
||||
|
||||
init(code: ReferralCode, binding: ReferralBinding?, bindingDeadline: Date? = nil) {
|
||||
self.code = code
|
||||
self.binding = binding
|
||||
self.bindingDeadline = bindingDeadline
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
code = try container.decode(ReferralCode.self, forKey: .code)
|
||||
binding = try container.decodeIfPresent(ReferralBinding.self, forKey: .binding)
|
||||
bindingDeadline = (try? container.decodeIfPresent(String.self, forKey: .bindingDeadline))
|
||||
.flatMap { $0 }
|
||||
.flatMap(ReferralCode.date(from:))
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(code, forKey: .code)
|
||||
try container.encodeIfPresent(binding, forKey: .binding)
|
||||
try container.encodeIfPresent(
|
||||
bindingDeadline.map(ReferralCode.string(from:)),
|
||||
forKey: .bindingDeadline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountCenterSnapshot: Equatable, Sendable {
|
||||
@@ -448,6 +502,38 @@ enum ReferralUniversalLink {
|
||||
return code
|
||||
}
|
||||
|
||||
/// Extracts a code from typed or pasted text: a short code, a bare long
|
||||
/// code, an invitation link, or a chat message that contains one of them.
|
||||
/// Links are matched with the same host and path rules as Universal Links,
|
||||
/// so arbitrary URLs never qualify. Short codes are returned normalized
|
||||
/// (uppercase, no hyphen); the server resolves them to the long code.
|
||||
static func code(fromUserInput text: String) -> String? {
|
||||
let separators = CharacterSet.whitespacesAndNewlines
|
||||
.union(CharacterSet(charactersIn: ",。;:、!?「」『』“”‘’<>()()【】《》"))
|
||||
let tokens = text
|
||||
.components(separatedBy: separators)
|
||||
.filter { !$0.isEmpty }
|
||||
for token in tokens {
|
||||
if isValid(code: token) {
|
||||
return token
|
||||
}
|
||||
// Chat apps often glue the link to the preceding sentence.
|
||||
let link = token.range(of: "https://", options: .caseInsensitive)
|
||||
.map { String(token[$0.lowerBound...]) } ?? token
|
||||
if let url = URL(string: link), let code = code(from: url) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
// Short codes go last: an 8-letter word in a pasted message must not
|
||||
// shadow the link that message carries.
|
||||
return ReferralShortCode.firstMatch(in: text)
|
||||
}
|
||||
|
||||
/// Anything the bind endpoint accepts: a long code or a normalized short code.
|
||||
static func isRedeemable(code: String) -> Bool {
|
||||
isValid(code: code) || ReferralShortCode.isCanonical(code)
|
||||
}
|
||||
|
||||
static func isValid(code: String) -> Bool {
|
||||
guard code.utf8.count == codeLength else { return false }
|
||||
return code.utf8.allSatisfy { byte in
|
||||
@@ -461,6 +547,73 @@ enum ReferralUniversalLink {
|
||||
|
||||
}
|
||||
|
||||
/// 8-character typeable alias of a referral code. Mirrors the server's
|
||||
/// `ReferralShortCode`: the alphabet drops 0/O and 1/I/L so a code read aloud
|
||||
/// or copied from a poster cannot be mistyped into another valid code.
|
||||
enum ReferralShortCode {
|
||||
static let length = 8
|
||||
static let alphabet: Set<Character> = Set("23456789ABCDEFGHJKMNPQRSTUVWXYZ")
|
||||
|
||||
static func isCanonical(_ value: String) -> Bool {
|
||||
value.count == length && value.allSatisfy(alphabet.contains)
|
||||
}
|
||||
|
||||
/// Accepts `K7M2Q9XA` or `k7m2-q9xa`; returns the stored uppercase form.
|
||||
static func normalize(_ raw: String) -> String? {
|
||||
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
if value.count == length + 1, value[value.index(value.startIndex, offsetBy: 4)] == "-" {
|
||||
value.remove(at: value.index(value.startIndex, offsetBy: 4))
|
||||
}
|
||||
return isCanonical(value) ? value : nil
|
||||
}
|
||||
|
||||
static func display(_ value: String) -> String {
|
||||
guard isCanonical(value) else { return value }
|
||||
return "\(value.prefix(4))-\(value.suffix(4))"
|
||||
}
|
||||
|
||||
/// Finds a short code inside free text such as "邀请码 K7M2-Q9XA" or a
|
||||
/// Chinese sentence glued to the code, which whitespace splitting misses.
|
||||
static func firstMatch(in text: String) -> String? {
|
||||
// Runs of code-like ASCII; anything else (spaces, CJK, punctuation)
|
||||
// separates them, so a run is exactly one candidate. "_" stays inside
|
||||
// a run so a fragment of a long code never reads as a short code.
|
||||
let runs = text.split { character in
|
||||
!(character.isASCII
|
||||
&& (character.isLetter || character.isNumber || character == "-" || character == "_"))
|
||||
}
|
||||
return runs.lazy.compactMap { normalize(String($0)) }.first
|
||||
}
|
||||
}
|
||||
|
||||
/// When a signed-in account may still bind an inviter. Prefers the server's
|
||||
/// `bindingDeadline`, which reflects the active campaign; without it, falls
|
||||
/// back to the default `REFERRAL_BINDING_DAYS`. The server stays
|
||||
/// authoritative, so a mismatch still surfaces as a redemption error.
|
||||
enum ReferralBindingWindow {
|
||||
static let duration: TimeInterval = 7 * 24 * 60 * 60
|
||||
|
||||
static func isOpen(for account: AccountSession, now: Date = Date()) -> Bool {
|
||||
// Zero means the account record has not loaded yet.
|
||||
guard account.createdAtEpochSeconds > 0 else { return false }
|
||||
let registeredAt = Date(timeIntervalSince1970: TimeInterval(account.createdAtEpochSeconds))
|
||||
return now.timeIntervalSince(registeredAt) < duration
|
||||
}
|
||||
|
||||
/// Open only while unbound and before the deadline the server reported.
|
||||
static func isOpen(
|
||||
for account: AccountSession,
|
||||
profile: ReferralProfile,
|
||||
now: Date = Date()
|
||||
) -> Bool {
|
||||
guard profile.binding == nil else { return false }
|
||||
if let deadline = profile.bindingDeadline {
|
||||
return now <= deadline
|
||||
}
|
||||
return isOpen(for: account, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol PendingReferralCodeStoring: AnyObject {
|
||||
var code: String? { get }
|
||||
@@ -480,7 +633,7 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
|
||||
|
||||
var code: String? {
|
||||
guard let value = defaults.string(forKey: Self.storageKey),
|
||||
ReferralUniversalLink.isValid(code: value)
|
||||
ReferralUniversalLink.isRedeemable(code: value)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
@@ -488,7 +641,7 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
|
||||
}
|
||||
|
||||
func save(_ code: String) {
|
||||
guard ReferralUniversalLink.isValid(code: code) else { return }
|
||||
guard ReferralUniversalLink.isRedeemable(code: code) else { return }
|
||||
defaults.set(code, forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ struct AccountCenterUITestHarness: View {
|
||||
"--account-snapshot-failure"
|
||||
),
|
||||
startsSignedOut: startsSignIn || staysSignedOut,
|
||||
signInDelay: startsSignIn ? .seconds(5) : .zero
|
||||
signInDelay: startsSignIn ? .seconds(5) : .zero,
|
||||
isInsideReferralWindow: arguments.contains("--account-referral-window")
|
||||
)
|
||||
self.startsSignIn = startsSignIn
|
||||
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
|
||||
@@ -122,15 +123,18 @@ private actor AccountCenterUITestService:
|
||||
private let shouldFailSnapshot: Bool
|
||||
private let startsSignedOut: Bool
|
||||
private let signInDelay: Duration
|
||||
private let isInsideReferralWindow: Bool
|
||||
|
||||
init(
|
||||
shouldFailSnapshot: Bool = false,
|
||||
startsSignedOut: Bool = false,
|
||||
signInDelay: Duration = .zero
|
||||
signInDelay: Duration = .zero,
|
||||
isInsideReferralWindow: Bool = false
|
||||
) {
|
||||
self.shouldFailSnapshot = shouldFailSnapshot
|
||||
self.startsSignedOut = startsSignedOut
|
||||
self.signInDelay = signInDelay
|
||||
self.isInsideReferralWindow = isInsideReferralWindow
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
@@ -180,9 +184,12 @@ private actor AccountCenterUITestService:
|
||||
code: "UITestInvite_123456789",
|
||||
inviteURL: URL(string: "https://osglab.com/i/UITestInvite_123456789")!,
|
||||
campaignID: nil,
|
||||
createdAt: Date(timeIntervalSince1970: 1_700_000_000)
|
||||
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
shortCode: "K7M2Q9XA"
|
||||
),
|
||||
binding: nil
|
||||
binding: nil,
|
||||
// The fixed account is years old; a server deadline reopens the window.
|
||||
bindingDeadline: isInsideReferralWindow ? Date().addingTimeInterval(3 * 86_400) : nil
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -111,12 +111,7 @@ struct AccountCenterView: View {
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(coordinator.operation != nil)
|
||||
|
||||
if coordinator.pendingReferralCode != nil {
|
||||
Label(AppL10n.string("account.referral.pendingAfterSignIn"), systemImage: "link")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
ReferralCodeEntryRow()
|
||||
|
||||
Text(AppL10n.string("account.signedOut.localUnaffected"))
|
||||
.font(.footnote)
|
||||
@@ -136,6 +131,10 @@ struct AccountCenterView: View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
accountSummarySection(session: session)
|
||||
SignedInReferralCodeCard(
|
||||
profile: coordinator.referralProfile,
|
||||
session: session
|
||||
)
|
||||
accountRewardsContent(session: session)
|
||||
securitySection
|
||||
}
|
||||
@@ -769,62 +768,63 @@ struct AccountCreditProgress: View {
|
||||
|
||||
struct AccountInvitationButton: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
@State private var showsShareDrawer = false
|
||||
|
||||
let invitationURL: URL?
|
||||
var shortCode: String?
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
guard invitationURL != nil else { return }
|
||||
showsShareDrawer = true
|
||||
} label: {
|
||||
Label(AppL10n.string("account.referral.inviteTitle"), systemImage: "person.badge.plus")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 38)
|
||||
.background(
|
||||
palette.success,
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
AccountReferralActionLabel(
|
||||
titleKey: "account.referral.inviteTitle",
|
||||
systemImage: "person.badge.plus"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(invitationURL == nil)
|
||||
.accessibilityIdentifier("account.referral.share")
|
||||
.sheet(isPresented: $showsShareDrawer) {
|
||||
if let invitationURL {
|
||||
AccountInvitationActivityView(invitationURL: invitationURL)
|
||||
.presentationDetents([.medium, .large])
|
||||
InviteShareSheet(
|
||||
invitationURL: invitationURL,
|
||||
shortCode: shortCode,
|
||||
referralProfile: coordinator.referralProfile
|
||||
)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountInvitationActivityView: UIViewControllerRepresentable {
|
||||
let invitationURL: URL
|
||||
/// Green rounded-rectangle label shared by the Invite and "I have a code"
|
||||
/// buttons so the two read as one family on the account card.
|
||||
struct AccountReferralActionLabel: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(
|
||||
activityItems: [invitationURL],
|
||||
applicationActivities: nil
|
||||
)
|
||||
controller.completionWithItemsHandler = { _, completed, _, _ in
|
||||
guard completed else { return }
|
||||
Task { @MainActor in
|
||||
AnalyticsHostService.shared.client.recordReferralShared()
|
||||
}
|
||||
}
|
||||
return controller
|
||||
let titleKey: String
|
||||
let systemImage: String
|
||||
/// Full-width form for a sheet's primary action.
|
||||
var fillsWidth = false
|
||||
|
||||
var body: some View {
|
||||
Label(AppL10n.string(titleKey), systemImage: systemImage)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(maxWidth: fillsWidth ? .infinity : nil, minHeight: fillsWidth ? 44 : 38)
|
||||
.background(
|
||||
palette.success,
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func updateUIViewController(
|
||||
_ uiViewController: UIActivityViewController,
|
||||
context: Context
|
||||
) {}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// AccountRewardsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Compact Home account surface. Signed-in users see the same credit progress
|
||||
// and invitation controls formerly shown in Settings; signed-out users see
|
||||
// the concrete free-credit paths before authenticating.
|
||||
// Quiet signed-in account summary on Home: credit progress plus invitation
|
||||
// controls. `HomeAccountSurfacePolicy` decides when it appears; signed-out and
|
||||
// problem states use `HomeAccountProblemCard` instead.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
@@ -13,58 +13,14 @@ struct AccountRewardsCard: View {
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
/// BYOK users keep credits as account metadata, so the card says which
|
||||
/// service is actually in use.
|
||||
var showsOwnKeyNote = false
|
||||
let onOpenAccount: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch coordinator.sessionPhase {
|
||||
case .restoring:
|
||||
loadingContent("account.settings.restoring")
|
||||
case .signedOut:
|
||||
signedOutContent
|
||||
case .signedIn:
|
||||
signedInContent
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
.task(id: coordinator.accountID) {
|
||||
guard coordinator.isSignedIn else { return }
|
||||
await coordinator.refreshAccountData()
|
||||
}
|
||||
}
|
||||
|
||||
private var signedOutContent: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
Image(systemName: "gift")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(AppL10n.string("account.signedOut.title"))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(AppL10n.string("account.signedOut.body"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(coordinator.operation != nil)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
signedInContent
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private var signedInContent: some View {
|
||||
@@ -90,10 +46,18 @@ struct AccountRewardsCard: View {
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
AccountReferralLinkView(viewModel: coordinator.referralProfile)
|
||||
AccountReferralLinkView(
|
||||
viewModel: coordinator.referralProfile,
|
||||
session: signedInSession
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var signedInSession: AccountSession? {
|
||||
guard case let .signedIn(session) = coordinator.sessionPhase else { return nil }
|
||||
return session
|
||||
}
|
||||
|
||||
private func creditSummary(_ snapshot: AccountCenterSnapshot) -> some View {
|
||||
let totalCredits = creditTotal(snapshot)
|
||||
return Button(action: onOpenAccount) {
|
||||
@@ -106,6 +70,11 @@ struct AccountRewardsCard: View {
|
||||
Text(AppL10n.string("account.credits.balance"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
if showsOwnKeyNote {
|
||||
Text(AppL10n.string("home.account.usingOwnKey"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
@@ -185,6 +154,9 @@ struct AccountRewardsCard: View {
|
||||
private struct AccountReferralLinkView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var viewModel: ReferralProfileViewModel
|
||||
let session: AccountSession?
|
||||
|
||||
@State private var isCodeEntryPresented = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -217,12 +189,27 @@ private struct AccountReferralLinkView: View {
|
||||
private func loadedContent(_ profile: ReferralProfile) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Text(AppL10n.string("account.referral.equalRewardDescription"))
|
||||
.font(TypeStyle.body.weight(.bold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(AppL10n.string(
|
||||
showsCodeEntry(profile)
|
||||
? "account.referral.codeEntry.windowDescription"
|
||||
: "account.referral.equalRewardDescription"
|
||||
))
|
||||
.font(TypeStyle.body.weight(.bold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
AccountInvitationButton(invitationURL: profile.code.inviteURL)
|
||||
if showsCodeEntry(profile) {
|
||||
// Inviting stays reachable from the bottom of the sheet.
|
||||
ReferralCodeEntryButton(
|
||||
isPresented: $isCodeEntryPresented,
|
||||
invitation: profile.code
|
||||
)
|
||||
} else {
|
||||
AccountInvitationButton(
|
||||
invitationURL: profile.code.inviteURL,
|
||||
shortCode: profile.code.displayShortCode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isRefreshing {
|
||||
@@ -239,6 +226,15 @@ private struct AccountReferralLinkView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// New accounts that can still bind an inviter see "I have a code" in
|
||||
/// place of Invite. The open sheet keeps the button mounted, so a
|
||||
/// redemption that closes the window cannot tear the sheet down mid-confirmation.
|
||||
private func showsCodeEntry(_ profile: ReferralProfile) -> Bool {
|
||||
if isCodeEntryPresented { return true }
|
||||
guard let session else { return false }
|
||||
return ReferralBindingWindow.isOpen(for: session, profile: profile)
|
||||
}
|
||||
|
||||
private func retryRow(
|
||||
messageKey: String,
|
||||
actionKey: String
|
||||
|
||||
@@ -10,9 +10,20 @@ import Foundation
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
#if canImport(OSGKeyboardHostSupport)
|
||||
import OSGKeyboardHostSupport
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AccountSessionCoordinator: ObservableObject {
|
||||
/// Result of an invitation the user typed or pasted instead of opening a link.
|
||||
enum ReferralInputOutcome: Equatable {
|
||||
case invalidInput
|
||||
case savedForSignIn
|
||||
case redeemed
|
||||
case failed(messageKey: String)
|
||||
}
|
||||
|
||||
enum SessionPhase: Equatable {
|
||||
case restoring
|
||||
case signedOut
|
||||
@@ -153,6 +164,10 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
operationErrorKey = nil
|
||||
operationErrorDetail = nil
|
||||
|
||||
#if DEBUG && os(iOS) && targetEnvironment(simulator)
|
||||
await adoptPendingDebugSessionHandoff()
|
||||
#endif
|
||||
|
||||
do {
|
||||
guard let session = try await sessionService.restoreSession() else {
|
||||
await sessionService.clearManagedGateway()
|
||||
@@ -208,11 +223,8 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
func handleIncomingURL(_ url: URL) -> Bool {
|
||||
guard let code = ReferralUniversalLink.code(from: url) else { return false }
|
||||
|
||||
pendingReferralStore.save(code)
|
||||
pendingReferralCode = code
|
||||
stagePendingReferral(code)
|
||||
shouldPresentAccountCenter = true
|
||||
operationErrorKey = nil
|
||||
operationErrorDetail = nil
|
||||
analyticsClient.recordInviteOpened()
|
||||
|
||||
if isSignedIn {
|
||||
@@ -224,6 +236,26 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Manual path for invitations that never reached the app as a Universal
|
||||
/// Link — typically a friend who scanned a poster, installed from the App
|
||||
/// Store, and typed the short code (or pasted the link). The outcome is
|
||||
/// returned to the caller instead of `operationErrorKey` so the code-entry
|
||||
/// sheet can explain it inline rather than through the global alert.
|
||||
func submitReferralInput(_ text: String) async -> ReferralInputOutcome {
|
||||
guard let code = ReferralUniversalLink.code(fromUserInput: text) else {
|
||||
return .invalidInput
|
||||
}
|
||||
stagePendingReferral(code)
|
||||
guard isSignedIn else { return .savedForSignIn }
|
||||
|
||||
if let messageKey = await redeemPendingReferral() {
|
||||
return .failed(messageKey: messageKey)
|
||||
}
|
||||
await referralProfile.refresh()
|
||||
await refreshAccountData(force: true)
|
||||
return .redeemed
|
||||
}
|
||||
|
||||
func consumeAccountCenterPresentation() -> Bool {
|
||||
guard shouldPresentAccountCenter else { return false }
|
||||
shouldPresentAccountCenter = false
|
||||
@@ -516,7 +548,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
private func expireSession(
|
||||
errorKey: String,
|
||||
errorKey: String?,
|
||||
signsOutService: Bool
|
||||
) async {
|
||||
guard isSignedIn else { return }
|
||||
@@ -544,22 +576,126 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
sessionRevision &+= 1
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Signs this app out without a server logout and writes the session to
|
||||
/// `directory`: the AirDrop file on a device, or the Simulator's own
|
||||
/// import location to park it (`debugResumeParkedSession()` brings it back).
|
||||
func debugRelinquishSession(writingTo directory: URL) async throws -> URL {
|
||||
guard isSignedIn, operation == nil else {
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
guard let handoff = sessionService as? any DebugAccountSessionHandingOff else {
|
||||
throw DebugAccountSessionHandoff.HandoffError.unsupportedService
|
||||
}
|
||||
let session = try await handoff.debugRelinquishSession()
|
||||
await expireSession(errorKey: nil, signsOutService: false)
|
||||
return try DebugAccountSessionHandoff.writeExportFile(session, directory: directory)
|
||||
}
|
||||
|
||||
#if os(iOS) && targetEnvironment(simulator)
|
||||
/// Re-runs restore, which adopts the parked handoff file first.
|
||||
func debugResumeParkedSession() async {
|
||||
guard !isSignedIn, operation == nil else { return }
|
||||
didAttemptRestore = false
|
||||
await restoreIfNeeded()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func adoptPendingDebugSessionHandoff() async {
|
||||
guard let handoff = sessionService as? any DebugAccountSessionHandingOff,
|
||||
let session = DebugAccountSessionHandoff.consumePendingImport() else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await handoff.debugAdoptSession(session)
|
||||
OSGDiag.log("debug session handoff adopted", category: "account")
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"debug session handoff failed error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func stagePendingReferral(_ code: String) {
|
||||
pendingReferralStore.save(code)
|
||||
pendingReferralCode = code
|
||||
operationErrorKey = nil
|
||||
operationErrorDetail = nil
|
||||
}
|
||||
|
||||
private func redeemPendingReferralIfNeeded() async {
|
||||
guard isSignedIn, let code = pendingReferralStore.code else { return }
|
||||
if let messageKey = await redeemPendingReferral() {
|
||||
operationErrorKey = messageKey
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns nil on success (or when nothing is pending), otherwise the
|
||||
/// message key. Rejections the server will repeat forever drop the stored
|
||||
/// code; transient failures keep it for the next sign-in or launch.
|
||||
private func redeemPendingReferral() async -> String? {
|
||||
guard isSignedIn, let code = pendingReferralStore.code else { return nil }
|
||||
|
||||
operation = .redeemingReferral
|
||||
defer { operation = nil }
|
||||
do {
|
||||
try await centerService.redeemReferral(code: code)
|
||||
pendingReferralStore.clear()
|
||||
pendingReferralCode = nil
|
||||
return nil
|
||||
} catch {
|
||||
pendingReferralCode = code
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.redeemReferral"
|
||||
)
|
||||
let failure = Self.referralRedemptionFailure(for: error)
|
||||
if failure.isPermanent {
|
||||
pendingReferralStore.clear()
|
||||
pendingReferralCode = nil
|
||||
} else {
|
||||
pendingReferralCode = code
|
||||
}
|
||||
return failure.messageKey
|
||||
}
|
||||
operation = nil
|
||||
}
|
||||
|
||||
private static func referralRedemptionFailure(
|
||||
for error: Error
|
||||
) -> (messageKey: String, isPermanent: Bool) {
|
||||
if error as? AccountIntegrationError == .unavailable {
|
||||
return ("account.error.unavailable", false)
|
||||
}
|
||||
guard let apiError = error as? AccountAPIError else {
|
||||
return ("account.error.redeemReferral", false)
|
||||
}
|
||||
// Referral routes answer with a bare `{"error": message}` body, so most
|
||||
// rejections arrive as `.server` and are told apart by status code.
|
||||
switch apiError {
|
||||
case .invalidRequest:
|
||||
return ("account.referral.redeem.error.invalid", true)
|
||||
case .conflict(let message):
|
||||
return (conflictMessageKey(for: message), true)
|
||||
case let .server(statusCode, _, message):
|
||||
switch statusCode {
|
||||
case 400, 404:
|
||||
return ("account.referral.redeem.error.invalid", true)
|
||||
case 409:
|
||||
return (conflictMessageKey(for: message), true)
|
||||
case 422:
|
||||
return ("account.referral.redeem.error.expired", true)
|
||||
default:
|
||||
return ("account.error.redeemReferral", false)
|
||||
}
|
||||
default:
|
||||
return ("account.error.redeemReferral", false)
|
||||
}
|
||||
}
|
||||
|
||||
private static func conflictMessageKey(for message: String) -> String {
|
||||
if message.localizedCaseInsensitiveContains("self-referral") {
|
||||
return "account.referral.redeem.error.self"
|
||||
}
|
||||
if message.localizedCaseInsensitiveContains("already bound") {
|
||||
return "account.referral.redeem.error.alreadyBound"
|
||||
}
|
||||
return "account.referral.redeem.error.ineligible"
|
||||
}
|
||||
|
||||
private func errorMessageKey(for error: Error, fallback: String) -> String {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// DebugAccountHandoffSection.swift
|
||||
// OSGKeyboard · Main App (DEBUG-only)
|
||||
//
|
||||
// Settings → Diagnostics controls for `DebugAccountSessionHandoff`: a device
|
||||
// hands its session off to the Simulator; the Simulator parks and resumes it
|
||||
// to switch between signed-out and signed-in without a server logout.
|
||||
// Debug tooling, so the copy is not localized.
|
||||
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
struct DebugAccountHandoffSection: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||
|
||||
@State private var showsConfirmation = false
|
||||
@State private var exportURL: URL?
|
||||
@State private var errorText: String?
|
||||
|
||||
var body: some View {
|
||||
CardSection(title: "模拟器账号交接 · DEBUG") {
|
||||
VStack(spacing: 0) {
|
||||
DebugAccountStatusRow(text: statusText, isError: errorText != nil)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
if let exportURL {
|
||||
ShareLink(item: exportURL) {
|
||||
DebugAccountActionRow(title: "AirDrop 到 Mac", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
showsConfirmation = true
|
||||
} label: {
|
||||
DebugAccountActionRow(
|
||||
title: "把账号会话交给模拟器",
|
||||
systemImage: "iphone.and.arrow.forward"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!accountSession.isSignedIn || accountSession.operation != nil)
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
.confirmationDialog(
|
||||
"把账号会话交给模拟器?",
|
||||
isPresented: $showsConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("交出会话", role: .destructive) { handOff() }
|
||||
Button("取消", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
"本机会退出登录,但不通知服务器,会话随文件转给模拟器。"
|
||||
+ "本机之后要用,重新登录即可。文件里是有效的登录凭证,不要转给别人。"
|
||||
)
|
||||
}
|
||||
.onDisappear { deleteExportFile() }
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let errorText { return errorText }
|
||||
if exportURL != nil {
|
||||
return "会话已从本机移除。AirDrop 到 Mac 后运行:"
|
||||
+ "Scripts/sim-import-account-session.sh <文件> [模拟器 UDID]"
|
||||
}
|
||||
return accountSession.isSignedIn
|
||||
? "模拟器无法生成设备证据,不能自己登录。把本机会话交过去,模拟器就能使用真实账号与托管云服务。"
|
||||
: "本机未登录。先登录,再交出会话。"
|
||||
}
|
||||
|
||||
private func handOff() {
|
||||
errorText = nil
|
||||
Task {
|
||||
do {
|
||||
exportURL = try await accountSession.debugRelinquishSession(
|
||||
writingTo: FileManager.default.temporaryDirectory
|
||||
)
|
||||
} catch {
|
||||
errorText = "交接失败(\(AccountDiagnostic.code(for: error))),请稍后重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteExportFile() {
|
||||
guard let exportURL else { return }
|
||||
try? FileManager.default.removeItem(at: exportURL)
|
||||
self.exportURL = nil
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct DebugAccountParkingSection: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||
|
||||
@State private var hasParkedSession = DebugAccountSessionHandoff.hasPendingImport()
|
||||
@State private var errorText: String?
|
||||
|
||||
var body: some View {
|
||||
CardSection(title: "模拟器登录状态 · DEBUG") {
|
||||
VStack(spacing: 0) {
|
||||
DebugAccountStatusRow(text: statusText, isError: errorText != nil)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
if accountSession.isSignedIn {
|
||||
Button(action: park) {
|
||||
DebugAccountActionRow(
|
||||
title: "暂时退出(保留会话)",
|
||||
systemImage: "pause.circle"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(accountSession.operation != nil)
|
||||
} else {
|
||||
Button(action: resume) {
|
||||
DebugAccountActionRow(title: "恢复登录", systemImage: "play.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!hasParkedSession || accountSession.operation != nil)
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
.onAppear { hasParkedSession = DebugAccountSessionHandoff.hasPendingImport() }
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let errorText { return errorText }
|
||||
if accountSession.isSignedIn {
|
||||
return "暂时退出只清掉本地登录状态,不通知服务器;之后点「恢复登录」或重启 App 即可回来。"
|
||||
+ "不要用「退出登录」,那会让服务器作废这个会话。"
|
||||
}
|
||||
return hasParkedSession
|
||||
? "会话已暂存在模拟器里,App 现在是未登录状态。"
|
||||
: "没有暂存的会话。用 Scripts/sim-import-account-session.sh 从真机交接一个。"
|
||||
}
|
||||
|
||||
private func park() {
|
||||
errorText = nil
|
||||
Task {
|
||||
do {
|
||||
_ = try await accountSession.debugRelinquishSession(
|
||||
writingTo: DebugAccountSessionHandoff.defaultImportDirectory
|
||||
)
|
||||
} catch {
|
||||
errorText = "暂存失败(\(AccountDiagnostic.code(for: error))),请稍后重试。"
|
||||
}
|
||||
hasParkedSession = DebugAccountSessionHandoff.hasPendingImport()
|
||||
}
|
||||
}
|
||||
|
||||
private func resume() {
|
||||
errorText = nil
|
||||
Task {
|
||||
await accountSession.debugResumeParkedSession()
|
||||
hasParkedSession = DebugAccountSessionHandoff.hasPendingImport()
|
||||
if !accountSession.isSignedIn {
|
||||
errorText = "恢复失败,会话可能已在服务器端失效,需要从真机重新交接。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct DebugAccountStatusRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let text: String
|
||||
let isError: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(verbatim: text)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(isError ? palette.warning : palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.settingsListRow()
|
||||
}
|
||||
}
|
||||
|
||||
private struct DebugAccountActionRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let title: String
|
||||
let systemImage: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 22)
|
||||
Text(verbatim: title)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.sm)
|
||||
}
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,101 @@
|
||||
// DebugAccountSessionHandoff.swift
|
||||
// OSGKeyboard · Main App (DEBUG-only)
|
||||
//
|
||||
// Moves a signed-in account session from a physical device to the Simulator,
|
||||
// which cannot produce DeviceCheck / App Attest evidence and so cannot sign in
|
||||
// itself. Flow:
|
||||
//
|
||||
// 1. Device: Settings → Diagnostics → hand off. The session is removed from
|
||||
// the device (no server logout) and written to a file for AirDrop.
|
||||
// 2. Mac: Scripts/sim-import-account-session.sh <file> [udid]
|
||||
// 3. Sim: on launch the app adopts the file, deletes it, and restores the
|
||||
// session normally.
|
||||
//
|
||||
// On the Simulator the same file parks the session, so the signed-out state
|
||||
// can be tested without a server logout ending the session.
|
||||
//
|
||||
// The file holds a live refresh token — treat it like a password.
|
||||
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
#if canImport(OSGKeyboardHostSupport)
|
||||
import OSGKeyboardHostSupport
|
||||
#endif
|
||||
|
||||
/// Implemented by the live account service; UI-test and unavailable services
|
||||
/// deliberately do not conform.
|
||||
protocol DebugAccountSessionHandingOff: Sendable {
|
||||
/// Revokes this device's gateway grant, then returns the session after
|
||||
/// removing it locally. The server-side session stays valid.
|
||||
func debugRelinquishSession() async throws -> AccountTokenSession
|
||||
func debugAdoptSession(_ session: AccountTokenSession) async throws
|
||||
}
|
||||
|
||||
enum DebugAccountSessionHandoff {
|
||||
static let fileName = "osg-account-handoff.json"
|
||||
static let format = "osg-debug-account-handoff-v1"
|
||||
private static let importedAccountKey = "debug.accountHandoff.importedAccountID"
|
||||
|
||||
private struct Envelope: Codable {
|
||||
let format: String
|
||||
let session: AccountTokenSession
|
||||
}
|
||||
|
||||
enum HandoffError: Error, Equatable {
|
||||
case unsupportedService
|
||||
}
|
||||
|
||||
static func encode(_ session: AccountTokenSession) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(Envelope(format: format, session: session))
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> AccountTokenSession? {
|
||||
guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data),
|
||||
envelope.format == format else {
|
||||
return nil
|
||||
}
|
||||
return envelope.session
|
||||
}
|
||||
|
||||
/// Device side: the file for the share sheet.
|
||||
static func writeExportFile(
|
||||
_ session: AccountTokenSession,
|
||||
directory: URL = FileManager.default.temporaryDirectory
|
||||
) throws -> URL {
|
||||
let url = directory.appendingPathComponent(fileName)
|
||||
try encode(session).write(to: url, options: [.atomic, .completeFileProtection])
|
||||
return url
|
||||
}
|
||||
|
||||
/// Simulator side: reads and always deletes the file the import script
|
||||
/// placed in Documents, so a malformed file cannot linger either.
|
||||
static func consumePendingImport(
|
||||
directory: URL = defaultImportDirectory
|
||||
) -> AccountTokenSession? {
|
||||
let url = directory.appendingPathComponent(fileName)
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return decode(data)
|
||||
}
|
||||
|
||||
static func hasPendingImport(directory: URL = defaultImportDirectory) -> Bool {
|
||||
FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName).path)
|
||||
}
|
||||
|
||||
static var defaultImportDirectory: URL {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
/// An imported session has no Apple credential for this Simulator's Apple
|
||||
/// ID, so the foreground credential check would sign it out immediately.
|
||||
static func markImported(accountID: UUID, defaults: UserDefaults = .standard) {
|
||||
defaults.set(accountID.uuidString, forKey: importedAccountKey)
|
||||
}
|
||||
|
||||
static func isImported(accountID: UUID, defaults: UserDefaults = .standard) -> Bool {
|
||||
defaults.string(forKey: importedAccountKey) == accountID.uuidString
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,329 @@
|
||||
// InvitePoster.swift
|
||||
// OSGKeyboard · Main App + Mac
|
||||
//
|
||||
// Shareable invitation artwork: the app icon, inviter name, a QR code for the
|
||||
// account's invitation link, and the short code for friends who type it
|
||||
// instead of scanning. The QR code is generated on device with Core Image,
|
||||
// so the link is never sent anywhere to be rendered. The Mac target compiles
|
||||
// this file at the source level (see project.yml), so it must stay free of
|
||||
// UIKit and AppKit.
|
||||
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import SwiftUI
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
enum InviteQRCode {
|
||||
/// Pixels per QR module. Integer scaling keeps module edges sharp.
|
||||
fileprivate static let moduleScale: CGFloat = 12
|
||||
|
||||
static func makeImage(for url: URL) -> CGImage? {
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(url.absoluteString.utf8)
|
||||
// "M" (15%) survives chat-app recompression. Nothing is drawn over the
|
||||
// code, so a higher level would only shrink the modules.
|
||||
filter.correctionLevel = "M"
|
||||
guard let output = filter.outputImage else { return nil }
|
||||
let scaled = output.transformed(
|
||||
by: CGAffineTransform(scaleX: moduleScale, y: moduleScale)
|
||||
)
|
||||
// QRGenerator produces a one-channel image that SwiftUI can treat as a
|
||||
// template and recolor white in dark mode. Materialize explicit black
|
||||
// and white pixels so both poster appearances remain scannable.
|
||||
let falseColor = CIFilter.falseColor()
|
||||
falseColor.inputImage = scaled
|
||||
falseColor.color0 = CIColor.black
|
||||
falseColor.color1 = CIColor.white
|
||||
guard let colored = falseColor.outputImage else { return nil }
|
||||
return CIContext().createCGImage(colored, from: colored.extent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed 3:4 layout so the exported image is the same on every device and
|
||||
/// is never cropped in chat previews or Moments.
|
||||
struct InvitePosterView: View {
|
||||
static let size = CGSize(width: 360, height: 480)
|
||||
|
||||
let qrImage: CGImage
|
||||
let language: AppUILanguage?
|
||||
let inviterName: String?
|
||||
let colorScheme: ColorScheme
|
||||
/// `XXXX-XXXX`; lets a friend who cannot scan type the code instead.
|
||||
var shortCode: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 28)
|
||||
|
||||
// Use the active Icon Composer artwork, not the horizontal logo.
|
||||
Image("AppIcon_Assets/OSGKeyboard Icon2")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(
|
||||
SharedL10n.format(
|
||||
"invite.poster.inviter",
|
||||
language: language,
|
||||
resolvedInviterName
|
||||
)
|
||||
)
|
||||
.font(.system(size: 18, weight: .medium, design: .default))
|
||||
.foregroundStyle(primaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.lineSpacing(3)
|
||||
.minimumScaleFactor(0.75)
|
||||
.padding(.top, 24)
|
||||
|
||||
// Dark modules on a light card scan most reliably, including in
|
||||
// WeChat's long-press recognizer.
|
||||
InviteQRCodeView(image: qrImage)
|
||||
.frame(width: 156, height: 156)
|
||||
.padding(Spacing.sm)
|
||||
.background(
|
||||
OSGColor.fixedLightContent,
|
||||
in: Rectangle()
|
||||
)
|
||||
.padding(.top, 28)
|
||||
|
||||
Text(SharedL10n.string("invite.poster.scan", language: language))
|
||||
.font(.system(size: 12, weight: .regular, design: .default))
|
||||
.foregroundStyle(secondaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 28)
|
||||
|
||||
if let shortCode {
|
||||
Text(SharedL10n.format("invite.poster.code", language: language, shortCode))
|
||||
.font(.system(size: 18, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(primaryContent)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
Spacer(minLength: 32)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xxl)
|
||||
.frame(width: Self.size.width, height: Self.size.height)
|
||||
.background(InvitePosterBackground(colorScheme: colorScheme))
|
||||
}
|
||||
|
||||
private var resolvedInviterName: String {
|
||||
let trimmed = inviterName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty
|
||||
? SharedL10n.string("invite.poster.defaultInviter", language: language)
|
||||
: trimmed
|
||||
}
|
||||
|
||||
private var primaryContent: Color {
|
||||
colorScheme == .dark ? Color(white: 0.96) : Color(white: 0.08)
|
||||
}
|
||||
|
||||
private var secondaryContent: Color {
|
||||
primaryContent.opacity(0.62)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws each QR module as a vector rectangle. `ImageRenderer` can otherwise
|
||||
/// reinterpret a CGImage as a template on complex dark backgrounds and erase
|
||||
/// its black modules. Vector modules stay black in every appearance.
|
||||
private struct InviteQRCodeView: View {
|
||||
private let sideCount: Int
|
||||
private let darkModules: [Bool]
|
||||
|
||||
init(image: CGImage) {
|
||||
let matrix = Self.moduleMatrix(from: image)
|
||||
sideCount = matrix.sideCount
|
||||
darkModules = matrix.darkModules
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
guard sideCount > 0 else { return }
|
||||
let moduleWidth = size.width / CGFloat(sideCount)
|
||||
let moduleHeight = size.height / CGFloat(sideCount)
|
||||
var modules = Path()
|
||||
|
||||
for row in 0..<sideCount {
|
||||
for column in 0..<sideCount where darkModules[row * sideCount + column] {
|
||||
modules.addRect(
|
||||
CGRect(
|
||||
x: CGFloat(column) * moduleWidth,
|
||||
y: CGFloat(row) * moduleHeight,
|
||||
width: moduleWidth,
|
||||
height: moduleHeight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
context.fill(modules, with: .color(OSGColor.fixedDarkContent))
|
||||
}
|
||||
.background(OSGColor.fixedLightContent)
|
||||
}
|
||||
|
||||
private static func moduleMatrix(
|
||||
from image: CGImage
|
||||
) -> (sideCount: Int, darkModules: [Bool]) {
|
||||
let sideCount = max(image.width / Int(InviteQRCode.moduleScale), 1)
|
||||
var pixels = [UInt8](repeating: 0, count: sideCount * sideCount * 4)
|
||||
guard let context = CGContext(
|
||||
data: &pixels,
|
||||
width: sideCount,
|
||||
height: sideCount,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: sideCount * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
return (0, [])
|
||||
}
|
||||
context.interpolationQuality = .none
|
||||
context.draw(
|
||||
image,
|
||||
in: CGRect(x: 0, y: 0, width: sideCount, height: sideCount)
|
||||
)
|
||||
let darkModules = (0..<(sideCount * sideCount)).map { index in
|
||||
let offset = index * 4
|
||||
let luminance = Int(pixels[offset])
|
||||
+ Int(pixels[offset + 1])
|
||||
+ Int(pixels[offset + 2])
|
||||
return luminance < 3 * 128
|
||||
}
|
||||
return (sideCount, darkModules)
|
||||
}
|
||||
}
|
||||
|
||||
/// A restrained paper surface: warm Nordic stock in light mode and graphite
|
||||
/// stock in dark mode. The daylight bands and fibers stay below text contrast,
|
||||
/// so the background adds depth without becoming decoration.
|
||||
private struct InvitePosterBackground: View {
|
||||
let colorScheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
base
|
||||
|
||||
LinearGradient(
|
||||
colors: daylightColors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Ellipse()
|
||||
.fill(topTrailingShadow)
|
||||
.frame(width: 310, height: 250)
|
||||
.offset(x: 178, y: -186)
|
||||
.blur(radius: 62)
|
||||
|
||||
Ellipse()
|
||||
.fill(bottomLeadingShadow)
|
||||
.frame(width: 300, height: 260)
|
||||
.offset(x: -182, y: 224)
|
||||
.blur(radius: 68)
|
||||
|
||||
Rectangle()
|
||||
.fill(diagonalShadow)
|
||||
.frame(width: proxy.size.width * 1.55, height: 82)
|
||||
.rotationEffect(.degrees(-18))
|
||||
.offset(x: 72, y: -118)
|
||||
.blur(radius: 34)
|
||||
|
||||
paperFibers(in: proxy.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var base: Color {
|
||||
colorScheme == .dark
|
||||
? Color(red: 0.055, green: 0.054, blue: 0.050)
|
||||
: Color(red: 0.965, green: 0.953, blue: 0.922)
|
||||
}
|
||||
|
||||
private var daylightColors: [Color] {
|
||||
if colorScheme == .dark {
|
||||
return [
|
||||
.white.opacity(0.085),
|
||||
.clear,
|
||||
.black.opacity(0.30)
|
||||
]
|
||||
}
|
||||
return [
|
||||
.white.opacity(0.62),
|
||||
.clear,
|
||||
Color(red: 0.52, green: 0.48, blue: 0.41).opacity(0.18)
|
||||
]
|
||||
}
|
||||
|
||||
private var topTrailingShadow: Color {
|
||||
colorScheme == .dark
|
||||
? .black.opacity(0.38)
|
||||
: Color(red: 0.43, green: 0.40, blue: 0.35).opacity(0.24)
|
||||
}
|
||||
|
||||
private var bottomLeadingShadow: Color {
|
||||
colorScheme == .dark
|
||||
? .black.opacity(0.28)
|
||||
: Color(red: 0.48, green: 0.44, blue: 0.37).opacity(0.15)
|
||||
}
|
||||
|
||||
private var diagonalShadow: Color {
|
||||
colorScheme == .dark
|
||||
? .white.opacity(0.055)
|
||||
: Color.black.opacity(0.055)
|
||||
}
|
||||
|
||||
private func paperFibers(in size: CGSize) -> some View {
|
||||
ZStack {
|
||||
ForEach(0..<48, id: \.self) { index in
|
||||
Rectangle()
|
||||
.fill(fiberColor)
|
||||
.frame(width: 2 + 7 * fraction(index * 19), height: 0.35)
|
||||
.position(
|
||||
x: size.width * fraction(index * 37),
|
||||
y: size.height * fraction(index * 61)
|
||||
)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private var fiberColor: Color {
|
||||
colorScheme == .dark
|
||||
? Color.white.opacity(0.025)
|
||||
: Color.black.opacity(0.025)
|
||||
}
|
||||
|
||||
private func fraction(_ value: Int) -> CGFloat {
|
||||
CGFloat(value % 101) / 100
|
||||
}
|
||||
}
|
||||
|
||||
enum InvitePosterRenderer {
|
||||
/// 3× the point layout: a 1080×1440 px image.
|
||||
static let exportScale: CGFloat = 3
|
||||
|
||||
@MainActor
|
||||
static func render(
|
||||
inviteURL: URL,
|
||||
shortCode: String? = nil,
|
||||
inviterName: String? = nil,
|
||||
language: AppUILanguage?,
|
||||
colorScheme: ColorScheme
|
||||
) -> CGImage? {
|
||||
guard let qrImage = InviteQRCode.makeImage(for: inviteURL) else { return nil }
|
||||
let renderer = ImageRenderer(
|
||||
content: InvitePosterView(
|
||||
qrImage: qrImage,
|
||||
language: language,
|
||||
inviterName: inviterName,
|
||||
colorScheme: colorScheme,
|
||||
shortCode: shortCode
|
||||
)
|
||||
)
|
||||
renderer.scale = exportScale
|
||||
return renderer.cgImage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// InviteQRFullScreenView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Face-to-face mode for the invitation: only the QR code, as large as the
|
||||
// screen allows, on white at full brightness so a friend's camera locks on
|
||||
// from arm's length. Tapping anywhere closes it.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct InviteQRFullScreenView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let qrImage: CGImage
|
||||
let language: AppUILanguage?
|
||||
|
||||
/// Past this size a phone held at arm's length no longer gains anything.
|
||||
private static let maxCodeSide: CGFloat = 440
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let side = min(
|
||||
min(proxy.size.width, proxy.size.height) - Spacing.xxl * 2,
|
||||
Self.maxCodeSide
|
||||
)
|
||||
VStack(spacing: Spacing.lg) {
|
||||
Image(decorative: qrImage, scale: 1)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: side, height: side)
|
||||
.accessibilityLabel(AppL10n.string("account.referral.poster.accessibility"))
|
||||
|
||||
Text(SharedL10n.string("invite.poster.hint", language: language))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(OSGColor.fixedDarkContent.opacity(0.6))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.background(OSGColor.fixedLightContent.ignoresSafeArea())
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(OSGColor.fixedDarkContent.opacity(0.6))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(Spacing.md)
|
||||
.accessibilityLabel(AppL10n.string("common.done"))
|
||||
.accessibilityIdentifier("account.referral.poster.fullScreen.close")
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { dismiss() }
|
||||
.boostsScreenBrightness()
|
||||
.statusBarHidden()
|
||||
.accessibilityAction(.escape) { dismiss() }
|
||||
.accessibilityIdentifier("account.referral.poster.fullScreen")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// InviteShareSheet.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Opened by the Invite button. Shows the invitation poster, which opens a
|
||||
// full-screen code for face-to-face scanning when tapped, plus the three ways an
|
||||
// invitation usually leaves the phone: saving the image to send in a chat app,
|
||||
// the system share sheet, or the plain link.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import Photos
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct InviteShareSheet: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
let invitationURL: URL
|
||||
/// Display form (`XXXX-XXXX`), printed on the poster when available.
|
||||
var shortCode: String?
|
||||
@ObservedObject var referralProfile: ReferralProfileViewModel
|
||||
|
||||
@State private var poster: UIImage?
|
||||
@State private var qrImage: CGImage?
|
||||
@State private var isShowingFullScreenCode = false
|
||||
@State private var isSharingPoster = false
|
||||
@State private var isSaving = false
|
||||
@State private var status: Status?
|
||||
|
||||
private enum Status: Equatable {
|
||||
case success(messageKey: String)
|
||||
case failure(messageKey: String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: Spacing.lg) {
|
||||
posterPreview
|
||||
actions
|
||||
statusMessage
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("account.referral.poster.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(AppL10n.string("common.done")) { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: posterRenderID) {
|
||||
qrImage = InviteQRCode.makeImage(for: invitationURL)
|
||||
poster = InvitePosterRenderer
|
||||
.render(
|
||||
inviteURL: invitationURL,
|
||||
shortCode: resolvedShortCode,
|
||||
inviterName: inviterName,
|
||||
language: config.uiLanguage,
|
||||
colorScheme: colorScheme
|
||||
)
|
||||
.map { UIImage(cgImage: $0) }
|
||||
}
|
||||
.task {
|
||||
// A cached pre-short-code profile may be visible for the first
|
||||
// frame. Refresh when the sheet opens and rerender once the server
|
||||
// supplies the account's assigned alias.
|
||||
guard shortCode == nil else { return }
|
||||
await referralProfile.refresh()
|
||||
}
|
||||
.fullScreenCover(isPresented: $isShowingFullScreenCode) {
|
||||
if let qrImage {
|
||||
InviteQRFullScreenView(qrImage: qrImage, language: config.uiLanguage)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isSharingPoster) {
|
||||
if let poster {
|
||||
InvitePosterActivityView(poster: poster)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("account.referral.poster")
|
||||
}
|
||||
|
||||
/// Tapping the poster opens the full-screen code for face-to-face scanning.
|
||||
private var posterPreview: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
return Button {
|
||||
isShowingFullScreenCode = true
|
||||
} label: {
|
||||
Group {
|
||||
if let poster {
|
||||
Image(uiImage: poster)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
ZStack {
|
||||
colorScheme == .dark
|
||||
? OSGColor.fixedDarkContent
|
||||
: OSGColor.fixedLightContent
|
||||
ProgressView()
|
||||
.tint(
|
||||
colorScheme == .dark
|
||||
? OSGColor.fixedLightContent
|
||||
: OSGColor.fixedDarkContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.aspectRatio(
|
||||
InvitePosterView.size.width / InvitePosterView.size.height,
|
||||
contentMode: .fit
|
||||
)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(posterPrimaryContent)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(posterPrimaryContent.opacity(0.16), in: Circle())
|
||||
.padding(Spacing.sm)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.clipShape(shape)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(qrImage == nil)
|
||||
.frame(maxWidth: 320)
|
||||
.cardElevation()
|
||||
.accessibilityLabel(AppL10n.string("account.referral.poster.accessibility"))
|
||||
.accessibilityHint(AppL10n.string("account.referral.poster.enlargeHint"))
|
||||
.accessibilityIdentifier("account.referral.poster.preview")
|
||||
}
|
||||
|
||||
private var inviterName: String? {
|
||||
guard case let .signedIn(session) = coordinator.sessionPhase else { return nil }
|
||||
return session.displayName
|
||||
}
|
||||
|
||||
private var resolvedShortCode: String? {
|
||||
if let shortCode { return shortCode }
|
||||
guard case let .loaded(profile) = referralProfile.state else { return nil }
|
||||
return profile.code.displayShortCode
|
||||
}
|
||||
|
||||
private var posterRenderID: String {
|
||||
"\(colorScheme)-\(resolvedShortCode ?? "")-\(inviterName ?? "")"
|
||||
}
|
||||
|
||||
private var posterPrimaryContent: Color {
|
||||
colorScheme == .dark
|
||||
? OSGColor.fixedLightContent
|
||||
: OSGColor.fixedDarkContent
|
||||
}
|
||||
|
||||
private var actions: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Button {
|
||||
Task { await savePoster() }
|
||||
} label: {
|
||||
Group {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
.tint(palette.textOnAccent)
|
||||
} else {
|
||||
Label(
|
||||
AppL10n.string("account.referral.poster.save"),
|
||||
systemImage: "square.and.arrow.down"
|
||||
)
|
||||
}
|
||||
}
|
||||
.primaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(poster == nil || isSaving)
|
||||
.accessibilityIdentifier("account.referral.poster.save")
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Button {
|
||||
isSharingPoster = true
|
||||
} label: {
|
||||
Label(
|
||||
AppL10n.string("account.referral.poster.share"),
|
||||
systemImage: "square.and.arrow.up"
|
||||
)
|
||||
.secondaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(poster == nil)
|
||||
.accessibilityIdentifier("account.referral.poster.share")
|
||||
|
||||
Button(action: copyLink) {
|
||||
Label(
|
||||
AppL10n.string("account.referral.poster.copyLink"),
|
||||
systemImage: "link"
|
||||
)
|
||||
.secondaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("account.referral.poster.copyLink")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusMessage: some View {
|
||||
switch status {
|
||||
case .success(let messageKey):
|
||||
Label(AppL10n.string(messageKey), systemImage: "checkmark.circle.fill")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.success)
|
||||
case .failure(let messageKey):
|
||||
Label(AppL10n.string(messageKey), systemImage: "exclamationmark.triangle.fill")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.danger)
|
||||
.multilineTextAlignment(.center)
|
||||
case nil:
|
||||
Text(AppL10n.string("account.referral.poster.footnote"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
private func savePoster() async {
|
||||
guard let poster, !isSaving else { return }
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
// PhotoKit terminates the process instead of throwing when this usage
|
||||
// description is missing from the built app. Keep this preflight so a
|
||||
// future project-generation regression becomes a visible error.
|
||||
guard let usageDescription = Bundle.main.object(
|
||||
forInfoDictionaryKey: "NSPhotoLibraryAddUsageDescription"
|
||||
) as? String, !usageDescription.isEmpty else {
|
||||
status = .failure(messageKey: "account.referral.poster.saveFailed")
|
||||
return
|
||||
}
|
||||
|
||||
// Add-only access: the app never needs to read the user's library.
|
||||
let authorization = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||
guard authorization == .authorized || authorization == .limited else {
|
||||
status = .failure(messageKey: "account.referral.poster.saveDenied")
|
||||
return
|
||||
}
|
||||
guard let pngData = poster.pngData() else {
|
||||
status = .failure(messageKey: "account.referral.poster.saveFailed")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await Self.addToPhotos(pngData: pngData)
|
||||
status = .success(messageKey: "account.referral.poster.saved")
|
||||
AnalyticsHostService.shared.client.recordReferralShared()
|
||||
} catch {
|
||||
status = .failure(messageKey: "account.referral.poster.saveFailed")
|
||||
}
|
||||
}
|
||||
|
||||
/// Photos runs the change block on its own transaction queue. It must be
|
||||
/// built outside the view's main-actor isolation: Swift 6 traps when a
|
||||
/// main-actor closure runs off the main thread.
|
||||
private nonisolated static func addToPhotos(pngData: Data) async throws {
|
||||
try await PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: pngData, options: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyLink() {
|
||||
UIPasteboard.general.string = invitationURL.absoluteString
|
||||
status = .success(messageKey: "account.referral.poster.linkCopied")
|
||||
AnalyticsHostService.shared.client.recordReferralShared()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares the poster image only. Chat apps' share extensions often refuse a
|
||||
/// mixed image + URL payload, and the link is one tap away via Copy Link.
|
||||
private struct InvitePosterActivityView: UIViewControllerRepresentable {
|
||||
let poster: UIImage
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(
|
||||
activityItems: [poster],
|
||||
applicationActivities: nil
|
||||
)
|
||||
controller.completionWithItemsHandler = { _, completed, _, _ in
|
||||
guard completed else { return }
|
||||
Task { @MainActor in
|
||||
AnalyticsHostService.shared.client.recordReferralShared()
|
||||
}
|
||||
}
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(
|
||||
_ uiViewController: UIActivityViewController,
|
||||
context: Context
|
||||
) {}
|
||||
}
|
||||
@@ -418,6 +418,12 @@ private actor LiveAccountService:
|
||||
}
|
||||
|
||||
func appleCredentialState() async -> AccountAppleCredentialState {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
if let session = try? await apiClient.currentSession(),
|
||||
DebugAccountSessionHandoff.isImported(accountID: session.accountId) {
|
||||
return .unknown
|
||||
}
|
||||
#endif
|
||||
let userIdentifier: String?
|
||||
do {
|
||||
userIdentifier = try await appleUserIdentifiers.loadAppleUserIdentifier()
|
||||
@@ -620,6 +626,26 @@ private actor LiveAccountService:
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension LiveAccountService: DebugAccountSessionHandingOff {
|
||||
func debugRelinquishSession() async throws -> AccountTokenSession {
|
||||
// Revoke while the session still exists; the grant is per device.
|
||||
await revokeManagedGrantIfPossible()
|
||||
let session = try await apiClient.debugRelinquishSession()
|
||||
await invalidateManagedGatewaySession()
|
||||
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||
return session
|
||||
}
|
||||
|
||||
func debugAdoptSession(_ session: AccountTokenSession) async throws {
|
||||
// Drop any grant cached for a previous account on this Simulator.
|
||||
await invalidateManagedGatewaySession()
|
||||
try await apiClient.debugAdoptSession(session)
|
||||
DebugAccountSessionHandoff.markImported(accountID: session.accountId)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Stable, non-identifying failure codes for account traffic. Logged on both
|
||||
/// platforms and — on macOS, where sign-in has no App Store receipt trail to
|
||||
/// fall back on — also shown under the error alert so a failure can be
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// ReferralCodeEntry.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Manual path for invitations that never opened the app directly. A friend
|
||||
// who scans a poster usually installs from the App Store, so the Universal
|
||||
// Link does not fire; instead they type the 8-character code printed on the
|
||||
// poster and the landing page. Pasting the full link into the field works
|
||||
// too. The clipboard is never read on its own.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
/// Green "I have a code" button, styled like the Invite button. The caller
|
||||
/// owns `isPresented` so it can keep the button on screen while the sheet is
|
||||
/// open, even if a successful redemption closes the binding window.
|
||||
struct ReferralCodeEntryButton: View {
|
||||
@Binding var isPresented: Bool
|
||||
/// Signed-in accounts also get an Invite entry at the bottom of the sheet.
|
||||
var invitation: ReferralCode?
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isPresented = true
|
||||
} label: {
|
||||
AccountReferralActionLabel(
|
||||
titleKey: "account.referral.codeEntry.button",
|
||||
systemImage: "gift"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("account.referral.codeEntry")
|
||||
.sheet(isPresented: $isPresented) {
|
||||
ReferralCodeEntrySheet(invitation: invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text field + confirm. Outcomes are shown inline rather than through the
|
||||
/// global alert; success closes the sheet after a short confirmation.
|
||||
struct ReferralCodeEntrySheet: View {
|
||||
typealias Outcome = AccountSessionCoordinator.ReferralInputOutcome
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
|
||||
var invitation: ReferralCode?
|
||||
|
||||
@State private var text = ""
|
||||
@State private var isSubmitting = false
|
||||
@State private var outcome: Outcome?
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
|
||||
/// Long enough to read the confirmation before the sheet closes.
|
||||
private static let successDismissDelay: Duration = .milliseconds(1_200)
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
Text(AppL10n.string("account.referral.codeEntry.sheetBody"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
codeField
|
||||
confirmButton
|
||||
feedback
|
||||
|
||||
if coordinator.isSignedIn, let invitation {
|
||||
inviteMore(invitation)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.md)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("account.referral.codeEntry.sheetTitle"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(AppL10n.string("common.cancel")) { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
.onAppear { isFieldFocused = true }
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.interactiveDismissDisabled(isSubmitting)
|
||||
.accessibilityIdentifier("account.referral.codeEntry.sheet")
|
||||
}
|
||||
|
||||
private var codeField: some View {
|
||||
TextField(AppL10n.string("account.referral.codeEntry.placeholder"), text: $text)
|
||||
.font(.system(.title3, design: .monospaced, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.tint(palette.textPrimary)
|
||||
.focused($isFieldFocused)
|
||||
// Codes are uppercase; links pasted into the field are unaffected.
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.asciiCapable)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { Task { await submit() } }
|
||||
.onChange(of: text) { _, _ in outcome = nil }
|
||||
.settingsListRow()
|
||||
.background(
|
||||
palette.formSurface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.accessibilityLabel(AppL10n.string("account.referral.codeEntry.fieldLabel"))
|
||||
.accessibilityIdentifier("account.referral.codeEntry.field")
|
||||
}
|
||||
|
||||
private var confirmButton: some View {
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
Group {
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.tint(OSGColor.fixedLightContent)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(
|
||||
palette.success,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
} else {
|
||||
AccountReferralActionLabel(
|
||||
titleKey: "account.referral.codeEntry.confirm",
|
||||
systemImage: "checkmark",
|
||||
fillsWidth: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSubmit)
|
||||
.opacity(canSubmit || isSubmitting ? 1 : 0.5)
|
||||
.accessibilityIdentifier("account.referral.codeEntry.confirm")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var feedback: some View {
|
||||
switch outcome {
|
||||
case .invalidInput:
|
||||
ReferralFeedbackLabel(messageKey: "account.referral.codeEntry.invalid", isSuccess: false)
|
||||
case .failed(let messageKey):
|
||||
ReferralFeedbackLabel(messageKey: messageKey, isSuccess: false)
|
||||
case .redeemed:
|
||||
ReferralFeedbackLabel(messageKey: "account.referral.codeEntry.redeemed", isSuccess: true)
|
||||
case .savedForSignIn:
|
||||
ReferralFeedbackLabel(messageKey: "account.referral.pendingAfterSignIn", isSuccess: true)
|
||||
case nil:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
/// The "invite in the sheet" decision: during the binding window the card
|
||||
/// shows "I have a code" instead of Invite, so inviting stays one tap away.
|
||||
private func inviteMore(_ invitation: ReferralCode) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
Divider().background(palette.divider)
|
||||
HStack(spacing: Spacing.md) {
|
||||
Text(AppL10n.string("account.referral.codeEntry.inviteMore"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
AccountInvitationButton(
|
||||
invitationURL: invitation.inviteURL,
|
||||
shortCode: invitation.displayShortCode
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, Spacing.sm)
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
!isSubmitting
|
||||
&& coordinator.operation == nil
|
||||
&& !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
guard canSubmit else { return }
|
||||
isSubmitting = true
|
||||
let result = await coordinator.submitReferralInput(text)
|
||||
isSubmitting = false
|
||||
outcome = result
|
||||
guard result == .redeemed || result == .savedForSignIn else { return }
|
||||
isFieldFocused = false
|
||||
try? await Task.sleep(for: Self.successDismissDelay)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// "Have an invitation?" row. Signed out it sits under the Apple button on
|
||||
/// Home and in the account center, and the code is held until sign-in.
|
||||
struct ReferralCodeEntryRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
|
||||
var invitation: ReferralCode?
|
||||
/// Pass a binding when the parent must know the sheet is open.
|
||||
var isEntryPresented: Binding<Bool>?
|
||||
|
||||
@State private var isLocallyPresented = false
|
||||
|
||||
private var presentation: Binding<Bool> {
|
||||
isEntryPresented ?? $isLocallyPresented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(AppL10n.string("account.referral.codeEntry.title"))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(AppL10n.string(
|
||||
coordinator.isSignedIn
|
||||
? "account.referral.codeEntry.bodySignedIn"
|
||||
: "account.referral.codeEntry.body"
|
||||
))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
ReferralCodeEntryButton(isPresented: presentation, invitation: invitation)
|
||||
.disabled(coordinator.operation != nil)
|
||||
}
|
||||
|
||||
if coordinator.pendingReferralCode != nil, !coordinator.isSignedIn {
|
||||
ReferralFeedbackLabel(messageKey: "account.referral.pendingAfterSignIn", isSuccess: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed-in variant for the account center: only accounts that have not
|
||||
/// bound an inviter and are still inside the binding window see it.
|
||||
struct SignedInReferralCodeCard: View {
|
||||
@ObservedObject var profile: ReferralProfileViewModel
|
||||
let session: AccountSession
|
||||
|
||||
@State private var isEntryPresented = false
|
||||
|
||||
var body: some View {
|
||||
if isEntryPresented || isEligible {
|
||||
ReferralCodeEntryRow(invitation: loadedCode, isEntryPresented: $isEntryPresented)
|
||||
.padding(Spacing.md)
|
||||
.surfaceCard()
|
||||
.accessibilityIdentifier("account.referral.codeEntryCard")
|
||||
}
|
||||
}
|
||||
|
||||
private var loadedCode: ReferralCode? {
|
||||
guard case let .loaded(loaded) = profile.state else { return nil }
|
||||
return loaded.code
|
||||
}
|
||||
|
||||
private var isEligible: Bool {
|
||||
guard case let .loaded(loaded) = profile.state else { return false }
|
||||
return ReferralBindingWindow.isOpen(for: session, profile: loaded)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReferralFeedbackLabel: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let messageKey: String
|
||||
let isSuccess: Bool
|
||||
|
||||
var body: some View {
|
||||
Label(
|
||||
AppL10n.string(messageKey),
|
||||
systemImage: isSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(isSuccess ? palette.success : palette.danger)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,13 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
case autoReplyGuidance
|
||||
case translateResult
|
||||
case yesNoReply
|
||||
/// Production fresh-copy state: Paste + ranked skill capsules in the top bar.
|
||||
case clipboardBar
|
||||
/// Dictation with a transcript long enough to fill the 3-line viewport.
|
||||
case liveTranscript
|
||||
}
|
||||
|
||||
@StateObject private var state = KeyboardState()
|
||||
@State private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
@State private var configured = false
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@@ -38,6 +42,13 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
return raw.flatMap { Scenario(rawValue: String($0)) } ?? .idle
|
||||
}()
|
||||
|
||||
/// `--assistant-compact-height`: render the landscape-iPhone tier inside the
|
||||
/// portrait-only host, since the simulator cannot rotate every device.
|
||||
private let forcesCompactHeightPhone = ProcessInfo.processInfo.arguments
|
||||
.contains("--assistant-compact-height")
|
||||
private let forcesExpandedPhone = ProcessInfo.processInfo.arguments
|
||||
.contains("--assistant-expanded-phone")
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
@@ -265,9 +276,9 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.skillTipText = "Skill failed"
|
||||
case .skills:
|
||||
// Keep the pagination assertion independent from production catalog
|
||||
// ordering: Navigate is guaranteed to appear after one swipe on both
|
||||
// four- and five-item pages.
|
||||
// Keep the scroll assertion independent from production catalog
|
||||
// ordering: Navigate is always the last top-bar capsule, past the
|
||||
// visible width on a phone.
|
||||
let navigate = AIClipboardSkillCatalog.catalog.first {
|
||||
$0.id == AIClipboardSkillCatalog.navigateID
|
||||
}
|
||||
@@ -294,6 +305,20 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
case .autoReplyGuidance:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.clipboardOverlay = .autoReplyGuide
|
||||
case .clipboardBar:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
let copied = "明天下午三点方便吗?"
|
||||
state.clipboardHistoryEnabled = true
|
||||
state.clipboardSuggestionText = copied
|
||||
state.clipboardSuggestionChangeCount = 2
|
||||
ClipboardHistoryStore.shared.ingest(rawText: copied, changeCount: 2)
|
||||
state.undoAvailable = true
|
||||
state.editAvailable = true
|
||||
case .liveTranscript:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.phase = .recording
|
||||
state.lastTranscript = "明天下午三点我们在会议室碰一下新版本的发布计划,"
|
||||
+ "顺便把横屏和折叠屏的适配问题过一遍,最后确认一下上线时间和回滚方案"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +326,8 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
let isIPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
state.layoutWidth = width
|
||||
state.usesIPadLayoutMetrics = isIPad
|
||||
state.usesCompactHeightPhoneLayoutMetrics = !isIPad && forcesCompactHeightPhone
|
||||
state.usesExpandedPhoneLayoutMetrics = !isIPad && !forcesCompactHeightPhone && forcesExpandedPhone
|
||||
state.showsSystemGlobeKey = isIPad
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// What's New 1.7.0 recording host. Voice chrome + clipboard panel are the
|
||||
// **real** extension views (`KeyboardTopControls`, `RecordButton`,
|
||||
// `KeyboardTranslationMenuButton`, `ClipboardHistoryPanelView`, …) driven by
|
||||
// scripted `KeyboardState`. Launch with `--clipboard-demo`.
|
||||
// scripted `KeyboardState`. Launch with `--clipboard-demo` and optional
|
||||
// `--whats-new-lang=zh|en`.
|
||||
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
@@ -20,7 +21,7 @@ struct ClipboardHistoryDemoView: View {
|
||||
(actionClusterTopGap - micToButtonGap) / 2
|
||||
}
|
||||
|
||||
@StateObject private var state = KeyboardState()
|
||||
@State private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
// `AIKeyboardView` ranks its skill row off `ClipboardHistoryStore.shared`
|
||||
// and `ClipboardSemanticRankingStore.shared`. Driving a private store here
|
||||
@@ -35,10 +36,17 @@ struct ClipboardHistoryDemoView: View {
|
||||
/// Closing beat: hand the copied text to the real AI skill row.
|
||||
@State private var showsSkills = false
|
||||
|
||||
private static let language = FeaturePreviewFlags.language
|
||||
private static var isEnglish: Bool { language == .en }
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
|
||||
init() {
|
||||
FeaturePreviewFlags.applyRecordingLanguage()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if FeaturePreviewFlags.isFullscreen {
|
||||
@@ -48,13 +56,10 @@ struct ClipboardHistoryDemoView: View {
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if FeaturePreviewFlags.isFullscreen {
|
||||
// Chinese-only recording — see the `zh-Hans` locale pin on
|
||||
// this view's body; the sample conversation has no English
|
||||
// variant, so the host chrome stays Chinese to match.
|
||||
FeaturePreviewHostDocument(
|
||||
kind: .messages,
|
||||
title: "信息",
|
||||
language: .zh,
|
||||
title: Self.isEnglish ? "Messages" : "信息",
|
||||
language: Self.language,
|
||||
text: hostDraft,
|
||||
incoming: Self.incomingMessage
|
||||
)
|
||||
@@ -71,7 +76,7 @@ struct ClipboardHistoryDemoView: View {
|
||||
}
|
||||
}
|
||||
.environment(\.themePalette, palette)
|
||||
.environment(\.locale, Locale(identifier: "zh-Hans"))
|
||||
.environment(\.locale, Locale(identifier: Self.isEnglish ? "en" : "zh-Hans"))
|
||||
.preferredColorScheme(.light)
|
||||
.task { await runTimeline() }
|
||||
}
|
||||
@@ -290,7 +295,9 @@ struct ClipboardHistoryDemoView: View {
|
||||
}) else { return }
|
||||
await runReplySkill(
|
||||
skill: generic,
|
||||
answer: "好的,预算版我整理一下,明天上午发你。"
|
||||
answer: Self.isEnglish
|
||||
? "Sure, I'll pull the budget version together and send it tomorrow morning."
|
||||
: "好的,预算版我整理一下,明天上午发你。"
|
||||
)
|
||||
state.pendingClipboardSkillID = nil
|
||||
try? await sleep(0.6)
|
||||
@@ -337,6 +344,7 @@ struct ClipboardHistoryDemoView: View {
|
||||
/// Demo copy per reply style. Keyed by skill so the answer matches whatever
|
||||
/// chip the ranker put in the row.
|
||||
private static func answer(for skillID: String) -> String {
|
||||
if isEnglish { return englishAnswer(for: skillID) }
|
||||
switch skillID {
|
||||
case AIClipboardSkillCatalog.playfulReplyID:
|
||||
return "行嘞,预算表这就去梳妆打扮,明早准时上桌。"
|
||||
@@ -353,6 +361,23 @@ struct ClipboardHistoryDemoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private static func englishAnswer(for skillID: String) -> String {
|
||||
switch skillID {
|
||||
case AIClipboardSkillCatalog.playfulReplyID:
|
||||
return "On it! The budget sheet is getting dressed up and will be on your desk by morning."
|
||||
case AIClipboardSkillCatalog.businessReplyID:
|
||||
return "Noted. I'll finalize the budget version tonight and send it over tomorrow morning."
|
||||
case AIClipboardSkillCatalog.empathyReplyID:
|
||||
return "Totally get that you need this soon. I'll prioritize it and send it first thing tomorrow."
|
||||
case AIClipboardSkillCatalog.acceptTaskID:
|
||||
return "Got it, I'll take this on and send the budget version tomorrow morning."
|
||||
case AIClipboardSkillCatalog.clarifyRequestID:
|
||||
return "Sure. Do you need the version with staffing costs, or just procurement?"
|
||||
default:
|
||||
return "Sure, I'll put it together and send it tomorrow morning."
|
||||
}
|
||||
}
|
||||
|
||||
/// One tap on a reply-style chip: the row gives way to the thinking state,
|
||||
/// the answer streams in, then it is offered for insertion.
|
||||
private func runReplySkill(skill: AIClipboardSkill, answer: String) async {
|
||||
@@ -394,7 +419,9 @@ struct ClipboardHistoryDemoView: View {
|
||||
state.layoutWidth = 390
|
||||
state.usesIPadLayoutMetrics = false
|
||||
state.showsSystemGlobeKey = false
|
||||
// `AIKeyboardView.showsClipboardSkills` gates on this — without it the
|
||||
// The skill ranker localizes chip titles off this.
|
||||
state.uiLanguage = Self.isEnglish ? .english : .chinese
|
||||
// `AIKeyboardView.showsClipboardSkillBar` gates on this — without it the
|
||||
// real skill row never appears no matter what the ranker returns.
|
||||
state.clipboardHistoryEnabled = true
|
||||
state.clipboardCandidateBarEnabled = true
|
||||
@@ -414,7 +441,10 @@ struct ClipboardHistoryDemoView: View {
|
||||
|
||||
private func seedHistory() {
|
||||
history.clearAll()
|
||||
_ = history.ingest(rawText: "订单号 OSG-20260811-8842", changeCount: 3)
|
||||
_ = history.ingest(
|
||||
rawText: Self.isEnglish ? "Order OSG-20260811-8842" : "订单号 OSG-20260811-8842",
|
||||
changeCount: 3
|
||||
)
|
||||
_ = history.ingest(rawText: "https://osglab.com", changeCount: 2)
|
||||
_ = history.ingest(rawText: Self.incomingMessage, changeCount: 1)
|
||||
history.reload()
|
||||
@@ -422,7 +452,11 @@ struct ClipboardHistoryDemoView: View {
|
||||
|
||||
/// The message the clip replies to. Copying it is what puts the reply
|
||||
/// skills in the row, so it has to be the newest clipboard entry.
|
||||
static let incomingMessage = "方案我看了,明天能不能把预算那一版也发我?"
|
||||
static var incomingMessage: String {
|
||||
isEnglish
|
||||
? "Read the proposal. Could you also send me the budget version tomorrow?"
|
||||
: "方案我看了,明天能不能把预算那一版也发我?"
|
||||
}
|
||||
|
||||
private func sleep(_ seconds: Double) async throws {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
|
||||
|
||||
@@ -20,6 +20,24 @@ enum WideLayoutMetrics {
|
||||
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
|
||||
static let pageHorizontalInset: CGFloat = 40
|
||||
static let dictationCanvasMinHeight: CGFloat = 120
|
||||
|
||||
/// Wide pages keep at least this much room between the window's top edge
|
||||
/// and their content, so the first row clears the rounded display corner.
|
||||
static let minimumTopClearance: CGFloat = Spacing.md
|
||||
|
||||
/// Extra top padding for wide pages. A status bar above the content
|
||||
/// (iPad, the portrait iPhone Duo inner display) already provides the
|
||||
/// room; the landscape inner display moves the status bar to the side and
|
||||
/// leaves a zero top safe area, so the content needs its own clearance.
|
||||
static func topCornerClearance(topSafeAreaInset: CGFloat) -> CGFloat {
|
||||
max(0, minimumTopClearance - topSafeAreaInset)
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// `WideLayoutMetrics.topCornerClearance` for the current window,
|
||||
/// published by `MainTabView`.
|
||||
@Entry var wideTopCornerClearance: CGFloat = 0
|
||||
}
|
||||
|
||||
// MARK: - Card container
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// ConceptFilmDemoView.swift
|
||||
// OSGKeyboard · Main App (DEBUG-only)
|
||||
//
|
||||
// Recording host for the vertical concept film. Each launch plays one scripted
|
||||
// beat on the **real** views — `AIKeyboardView` and `PersonalReplyStyleSection`
|
||||
// — with no ASR, no LLM, and no network:
|
||||
//
|
||||
// --concept-film=voice rambling dictation → polished text lands in Notes
|
||||
// --concept-film=style personal reply style distilled from past dictation
|
||||
// --concept-film=reply copied message → three reply choices → sent
|
||||
//
|
||||
// Optional `--whats-new-lang=zh|en`. Timelines run at wall-clock speed; the
|
||||
// film edit trims and retimes them.
|
||||
//
|
||||
// Simulator-only: the style scene clears speech history and replaces the style
|
||||
// catalog. Those writes carry iCloud-synced tombstones, so on a personal device
|
||||
// they would wipe the same data on every device signed into that account.
|
||||
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct ConceptFilmDemoView: View {
|
||||
enum Scene: String {
|
||||
case voice
|
||||
case style
|
||||
case reply
|
||||
}
|
||||
|
||||
let scene: Scene
|
||||
|
||||
static var requestedScene: Scene? {
|
||||
#if !targetEnvironment(simulator)
|
||||
return nil
|
||||
#else
|
||||
let prefix = "--concept-film="
|
||||
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||
where: { $0.hasPrefix(prefix) }
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return Scene(rawValue: String(argument.dropFirst(prefix.count)))
|
||||
#endif
|
||||
}
|
||||
|
||||
private var language: WhatsNewDemoScenario.Language {
|
||||
let prefix = "--whats-new-lang="
|
||||
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||
where: { $0.hasPrefix(prefix) }
|
||||
) else {
|
||||
return .zh
|
||||
}
|
||||
return WhatsNewDemoScenario.Language(
|
||||
rawValue: String(argument.dropFirst(prefix.count))
|
||||
) ?? .zh
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch scene {
|
||||
case .voice:
|
||||
ConceptVoiceScene(language: language)
|
||||
case .style:
|
||||
ConceptStyleScene(language: language)
|
||||
case .reply:
|
||||
ConceptReplyScene(language: language)
|
||||
}
|
||||
}
|
||||
.environment(\.locale, language.locale)
|
||||
.preferredColorScheme(.light)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice: 你说 / You speak
|
||||
|
||||
private struct ConceptVoiceScene: View {
|
||||
let language: WhatsNewDemoScenario.Language
|
||||
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@State private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
@State private var hostText = ""
|
||||
|
||||
init(language: WhatsNewDemoScenario.Language) {
|
||||
self.language = language
|
||||
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(uiColor: .systemGroupedBackground).ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
FeaturePreviewHostDocument(
|
||||
kind: .notes,
|
||||
title: language == .en ? "Notes" : "备忘录",
|
||||
language: language,
|
||||
text: hostText
|
||||
)
|
||||
AIKeyboardView(state: state, typing: typing, onInsert: { _ in })
|
||||
.background(Palette.light.background.ignoresSafeArea(edges: .bottom))
|
||||
}
|
||||
}
|
||||
.conceptKeyboardWidth(state)
|
||||
.task { await runTimeline() }
|
||||
}
|
||||
|
||||
/// Idle → recording with the raw transcript streaming in (fillers and a
|
||||
/// self-correction included) → processing → the polished sentence lands
|
||||
/// in the note while the mic flashes the success state.
|
||||
private func runTimeline() async {
|
||||
config.uiLanguage = language.uiLanguage
|
||||
state.prepareConceptKeyboard()
|
||||
await pause(1.2)
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
state.phase = .recording
|
||||
}
|
||||
for chunk in ConceptFilmScript.spokenChunks(language) {
|
||||
state.lastTranscript += chunk
|
||||
// Several level ticks per chunk keep the waveform alive between words.
|
||||
for _ in 0..<3 {
|
||||
state.level = Double.random(in: 0.3...0.9)
|
||||
await pause(0.1)
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
state.phase = .processing
|
||||
state.level = 0
|
||||
}
|
||||
await pause(1.0)
|
||||
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
state.assistantInsertionSucceeded = true
|
||||
hostText = ConceptFilmScript.polishedText(language)
|
||||
}
|
||||
await pause(1.6)
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
state.assistantInsertionSucceeded = false
|
||||
}
|
||||
await pause(60)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Style: 它学 / It learns
|
||||
|
||||
private struct ConceptStyleScene: View {
|
||||
let language: WhatsNewDemoScenario.Language
|
||||
|
||||
init(language: WhatsNewDemoScenario.Language) {
|
||||
self.language = language
|
||||
ConceptStyleFixture.prepareOnce(language: language)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ThemedRoot {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
PersonalReplyStyleSection(generator: { _, _, _, _ in
|
||||
try await Task.sleep(for: .seconds(2.4))
|
||||
ConceptStyleFixture.saveLearnedStyle(language: language)
|
||||
// Same reload path a cloud merge takes, so the card
|
||||
// flips to the saved style without the review sheet.
|
||||
withAnimation(.snappy(duration: 0.45)) {
|
||||
NotificationCenter.default.post(
|
||||
name: .polishStylesDidSyncFromCloud,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
throw CancellationError()
|
||||
})
|
||||
}
|
||||
}
|
||||
.background(Palette.light.background)
|
||||
.navigationTitle(AppL10n.string("skills.title"))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum ConceptStyleFixture {
|
||||
private static var didPrepare = false
|
||||
|
||||
/// SwiftUI may re-create the scene struct; seeding again would reset the
|
||||
/// card mid-take.
|
||||
static func prepareOnce(language: WhatsNewDemoScenario.Language) {
|
||||
guard !didPrepare else { return }
|
||||
didPrepare = true
|
||||
|
||||
ProviderConfig.shared.uiLanguage = language.uiLanguage
|
||||
PersonalReplyStyleSection.debugUsesReleaseCharacterGate = true
|
||||
PersonalReplyStyleSection.debugAutoGenerationDelay = .seconds(1.6)
|
||||
|
||||
let store = AppGroupStore()
|
||||
store.setPolishStyleCatalog(PolishStyleCatalog())
|
||||
store.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
|
||||
store.setPersonalReplyStyleId("")
|
||||
|
||||
// Exactly the release gate, so the card reads "2500 / 2500" and ready.
|
||||
let history = SpeechHistoryStore.shared
|
||||
history.clearAll()
|
||||
let token = language == .en ? "NaturalVoice" : "自然表达习惯"
|
||||
let sample = String(
|
||||
String(repeating: token, count: 500)
|
||||
.prefix(PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount)
|
||||
)
|
||||
history.append(
|
||||
text: sample,
|
||||
prePolishText: sample,
|
||||
engineMode: "local",
|
||||
source: .dictation
|
||||
)
|
||||
history.reloadFromDisk()
|
||||
}
|
||||
|
||||
static func saveLearnedStyle(language: WhatsNewDemoScenario.Language) {
|
||||
let isEnglish = language == .en
|
||||
let pack = PolishStylePack(
|
||||
name: isEnglish ? "My Speaking Style" : "我的说话风格",
|
||||
prompt: isEnglish
|
||||
? "Preserve the user's natural, direct voice. Respond first, add only necessary detail."
|
||||
: "保留用户自然、直接的表达方式。先回应对方,再补充必要信息。",
|
||||
learningMetadata: PolishStylePack.LearningMetadata(
|
||||
schemaVersion: 2,
|
||||
evidenceStatus: "sufficient",
|
||||
confidence: 0.86,
|
||||
asrExampleCount: 42,
|
||||
asrEffectiveCharacterCount: 2_735,
|
||||
replyExampleCount: 12,
|
||||
replyFinalEditCount: 4,
|
||||
generatedAt: Date()
|
||||
)
|
||||
)
|
||||
var catalog = PolishStyleCatalog()
|
||||
try? catalog.upsert(pack)
|
||||
let store = AppGroupStore()
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
store.setPersonalReplyStyleId(pack.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reply: 它替你说 / It speaks for you
|
||||
|
||||
private struct ConceptReplyScene: View {
|
||||
let language: WhatsNewDemoScenario.Language
|
||||
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@State private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
@State private var hostText = ""
|
||||
@State private var hostSent = false
|
||||
|
||||
init(language: WhatsNewDemoScenario.Language) {
|
||||
self.language = language
|
||||
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(uiColor: .systemGroupedBackground).ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
FeaturePreviewHostDocument(
|
||||
kind: .messages,
|
||||
title: language == .en ? "Messages" : "信息",
|
||||
language: language,
|
||||
text: hostText,
|
||||
incoming: ConceptFilmScript.incomingMessage(language),
|
||||
isSent: hostSent
|
||||
)
|
||||
AIKeyboardView(state: state, typing: typing, onInsert: { _ in })
|
||||
.background(Palette.light.background.ignoresSafeArea(edges: .bottom))
|
||||
}
|
||||
}
|
||||
.conceptKeyboardWidth(state)
|
||||
.task { await runTimeline() }
|
||||
}
|
||||
|
||||
/// Copied message → thinking → three reply choices → the first (the
|
||||
/// user's everyday voice) is chosen, inserted, and sent.
|
||||
private func runTimeline() async {
|
||||
config.uiLanguage = language.uiLanguage
|
||||
state.prepareConceptKeyboard()
|
||||
state.clipboardHistoryEnabled = true
|
||||
state.clipboardSuggestionText = ConceptFilmScript.incomingMessage(language)
|
||||
await pause(1.4)
|
||||
|
||||
let utteranceID = UUID()
|
||||
state.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||
state.aiSession.beginGenerating(question: "", utteranceID: utteranceID)
|
||||
await pause(1.2)
|
||||
|
||||
let variants = ConceptFilmScript.replyVariants(language)
|
||||
withAnimation(.snappy(duration: 0.4)) {
|
||||
state.aiSession.receiveReplyVariants(variants, utteranceID: utteranceID)
|
||||
}
|
||||
await pause(2.8)
|
||||
|
||||
guard let chosen = variants.first else { return }
|
||||
withAnimation(.snappy(duration: 0.3)) {
|
||||
_ = state.aiSession.selectReplyVariant(id: chosen.id)
|
||||
}
|
||||
await pause(0.9)
|
||||
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
state.aiSession.markAnswerInserted(offersSend: true)
|
||||
hostText = chosen.text
|
||||
}
|
||||
await pause(1.0)
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
state.aiSession.markAnswerSent()
|
||||
hostSent = true
|
||||
}
|
||||
await pause(60)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Script
|
||||
|
||||
private enum ConceptFilmScript {
|
||||
/// Raw ASR as it streams: fillers plus an explicit self-correction, both
|
||||
/// of which the polish prompt removes (F1 / R4 in `PolishPromptComposer`).
|
||||
static func spokenChunks(_ language: WhatsNewDemoScenario.Language) -> [String] {
|
||||
language == .en
|
||||
? ["Um, ", "so ", "let's meet ", "tomorrow at ", "two, ", "oh wait, ",
|
||||
"three, ", "to go over ", "the, uh, ", "proposal"]
|
||||
: ["嗯,", "那个,", "明天下午", "两点,", "哦不对,", "三点,",
|
||||
"开个会", "讨论一下", "那个方案"]
|
||||
}
|
||||
|
||||
static func polishedText(_ language: WhatsNewDemoScenario.Language) -> String {
|
||||
language == .en
|
||||
? "Let's meet tomorrow at three to go over the proposal."
|
||||
: "明天下午三点开会,讨论一下方案。"
|
||||
}
|
||||
|
||||
static func incomingMessage(_ language: WhatsNewDemoScenario.Language) -> String {
|
||||
language == .en
|
||||
? "Read the proposal. Can you send the budget version tomorrow?"
|
||||
: "方案我看了,明天能不能把预算那一版也发我?"
|
||||
}
|
||||
|
||||
/// English lines are kept short so all three rows fit the keyboard band
|
||||
/// without scrolling, matching the 1 / 2 / 1 line rhythm of the Chinese take.
|
||||
static func replyVariants(_ language: WhatsNewDemoScenario.Language) -> [AIReplyVariant] {
|
||||
if language == .en {
|
||||
return [
|
||||
AIReplyVariant(
|
||||
kind: .ordinary,
|
||||
emotion: .neutral,
|
||||
text: "Sure, I'll send it over tomorrow."
|
||||
),
|
||||
AIReplyVariant(
|
||||
kind: .formal,
|
||||
emotion: .neutral,
|
||||
text: "Received. I'll send the budget version to you tomorrow morning."
|
||||
),
|
||||
AIReplyVariant(
|
||||
kind: .playful,
|
||||
emotion: .playful,
|
||||
text: "You got it, budget's on its way!"
|
||||
)
|
||||
]
|
||||
}
|
||||
return [
|
||||
AIReplyVariant(
|
||||
kind: .ordinary,
|
||||
emotion: .neutral,
|
||||
text: "没问题,预算那版我今晚整理好,明早发你。"
|
||||
),
|
||||
AIReplyVariant(
|
||||
kind: .formal,
|
||||
emotion: .neutral,
|
||||
text: "收到,预算版本今晚整理完毕,明日上午发送给您。"
|
||||
),
|
||||
AIReplyVariant(
|
||||
kind: .playful,
|
||||
emotion: .playful,
|
||||
text: "行嘞~预算表这就去梳妆打扮,明早准时上桌!"
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared helpers
|
||||
|
||||
private extension WhatsNewDemoScenario.Language {
|
||||
var locale: Locale {
|
||||
self == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans")
|
||||
}
|
||||
|
||||
var uiLanguage: AppUILanguage {
|
||||
self == .en ? .english : .chinese
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyboardState {
|
||||
func prepareConceptKeyboard() {
|
||||
surface = .voice
|
||||
aiServiceAvailable = true
|
||||
micDisabled = false
|
||||
usesIPadLayoutMetrics = false
|
||||
micVoiceAvailability = .ready
|
||||
aiSession.enter()
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
/// Keyboard metrics follow the real screen width instead of a fixed 390 pt.
|
||||
func conceptKeyboardWidth(_ state: KeyboardState) -> some View {
|
||||
onGeometryChange(for: CGFloat.self) { proxy in
|
||||
proxy.size.width
|
||||
} action: { width in
|
||||
state.layoutWidth = width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pause(_ seconds: Double) async {
|
||||
try? await Task.sleep(for: .seconds(seconds))
|
||||
}
|
||||
#endif
|
||||
@@ -4,26 +4,42 @@
|
||||
// What's New "Edit last input" recording host. Uses the **real**
|
||||
// `LastInputEditView` (compiled into the app target) driven by a scripted
|
||||
// `KeyboardState` — no ASR / LLM. Opening beat shows the voice mic + hint.
|
||||
// Launch with `--edit-demo`. Not shipped in Release.
|
||||
// Launch with `--edit-demo` and optional `--whats-new-lang=zh|en`.
|
||||
// Not shipped in Release.
|
||||
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct EditDemoView: View {
|
||||
private static let originalText = "明天下午三点开会讨论方案"
|
||||
private static let editedText = "各位好,明天下午三点在 A 会议室召开方案讨论会,请准时参加。"
|
||||
private static let language = FeaturePreviewFlags.language
|
||||
|
||||
private static var originalText: String {
|
||||
language == .en
|
||||
? "meeting tomorrow at 3 to discuss the plan"
|
||||
: "明天下午三点开会讨论方案"
|
||||
}
|
||||
|
||||
private static var editedText: String {
|
||||
language == .en
|
||||
? "Hi all, we'll review the proposal tomorrow at 3 PM in Room A. Please be on time."
|
||||
: "各位好,明天下午三点在 A 会议室召开方案讨论会,请准时参加。"
|
||||
}
|
||||
|
||||
private enum Scene: Equatable {
|
||||
case hint
|
||||
case editing
|
||||
}
|
||||
|
||||
@StateObject private var state = KeyboardState()
|
||||
@State private var state = KeyboardState()
|
||||
@State private var scene: Scene = .hint
|
||||
/// Mirrors the host document for `--preview-fullscreen` recordings.
|
||||
@State private var hostText: String = EditDemoView.originalText
|
||||
|
||||
init() {
|
||||
FeaturePreviewFlags.applyRecordingLanguage()
|
||||
}
|
||||
|
||||
private var source: EditSessionSource {
|
||||
let reference = EditableInputReference(
|
||||
displayText: Self.originalText,
|
||||
@@ -45,13 +61,10 @@ struct EditDemoView: View {
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if FeaturePreviewFlags.isFullscreen {
|
||||
// Chinese-only recording: the sample text below has no
|
||||
// English variant, so the host chrome is pinned to match
|
||||
// (see the `zh-Hans` locale pin on this view's body).
|
||||
FeaturePreviewHostDocument(
|
||||
kind: .notes,
|
||||
title: "备忘录",
|
||||
language: .zh,
|
||||
title: Self.language == .en ? "Notes" : "备忘录",
|
||||
language: Self.language,
|
||||
text: hostText
|
||||
)
|
||||
} else {
|
||||
@@ -74,7 +87,7 @@ struct EditDemoView: View {
|
||||
}
|
||||
}
|
||||
.environment(\.themePalette, palette)
|
||||
.environment(\.locale, Locale(identifier: "zh-Hans"))
|
||||
.environment(\.locale, Locale(identifier: Self.language == .en ? "en" : "zh-Hans"))
|
||||
.preferredColorScheme(.light)
|
||||
.task { await runTimeline() }
|
||||
}
|
||||
|
||||
@@ -47,19 +47,11 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
.alert(
|
||||
AppL10n.string("settings.aiService.credits.consent.title"),
|
||||
isPresented: $showsManagedCloudConsent
|
||||
) {
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
Button(AppL10n.string("settings.aiService.credits.consent.accept")) {
|
||||
config.hasAcknowledgedCloudSharing = true
|
||||
activateManagedService()
|
||||
}
|
||||
.accessibilityIdentifier("settings.aiService.credits.consent.accept")
|
||||
} message: {
|
||||
Text(AppL10n.string("settings.aiService.credits.consent.message"))
|
||||
}
|
||||
.managedCloudConsentAlert(
|
||||
isPresented: $showsManagedCloudConsent,
|
||||
config: config,
|
||||
onAccept: activateManagedService
|
||||
)
|
||||
}
|
||||
|
||||
private func serviceOptionRow(
|
||||
@@ -118,13 +110,10 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
|
||||
private func activateManagedService() {
|
||||
Task {
|
||||
if await accountSession.prepareManagedGateway() {
|
||||
withAnimation(Motion.quick) {
|
||||
config.engineMode = "cloud"
|
||||
config.modeId = "polish"
|
||||
config.credentialSource = .managed
|
||||
}
|
||||
}
|
||||
await ManagedServiceActivation.activate(
|
||||
config: config,
|
||||
accountSession: accountSession
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,26 @@ enum FeaturePreviewFlags {
|
||||
static var isFullscreen: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("--preview-fullscreen")
|
||||
}
|
||||
|
||||
/// Recording language from `--whats-new-lang=zh|en`; Chinese when absent.
|
||||
static var language: WhatsNewDemoScenario.Language {
|
||||
let prefix = "--whats-new-lang="
|
||||
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||
where: { $0.hasPrefix(prefix) }
|
||||
) else {
|
||||
return .zh
|
||||
}
|
||||
return WhatsNewDemoScenario.Language(
|
||||
rawValue: String(argument.dropFirst(prefix.count))
|
||||
) ?? .zh
|
||||
}
|
||||
|
||||
/// Points the extension strings (`ExtL10n` reads the App Group setting)
|
||||
/// at the recording language before the first frame renders.
|
||||
@MainActor
|
||||
static func applyRecordingLanguage() {
|
||||
ProviderConfig.shared.uiLanguage = language == .en ? .english : .chinese
|
||||
}
|
||||
}
|
||||
|
||||
/// A Notes- or Messages-like document that reacts to the demo timeline.
|
||||
|
||||
@@ -73,6 +73,12 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
corpusExportSection
|
||||
}
|
||||
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
DebugAccountParkingSection()
|
||||
#elseif DEBUG
|
||||
DebugAccountHandoffSection()
|
||||
#endif
|
||||
|
||||
CardSection(title: AppL10n.string("settings.diagnostics.privacy.section")) {
|
||||
Text(privacyBodyText)
|
||||
.font(TypeStyle.caption2)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// HomeAccountSurfacePolicy.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Decides the single account / setup surface Home shows above the usage
|
||||
// metrics: a problem card or the quiet account summary, never both, so Home
|
||||
// carries at most one account-related card on iPhone, iPhone Duo (folded or
|
||||
// unfolded), and iPad.
|
||||
|
||||
import CoreGraphics
|
||||
import OSGKeyboardShared
|
||||
|
||||
/// Which AI service the current configuration can actually serve right now.
|
||||
enum HomeServiceReadiness: Equatable {
|
||||
/// OSG credits through the managed gateway.
|
||||
case credits
|
||||
/// The user's own API keys for recognition and polish.
|
||||
case ownKey
|
||||
/// Dictation works (on-device, or BYOK cloud ASR) but AI polish has no key.
|
||||
case dictationOnly
|
||||
/// Nothing can transcribe yet: cloud ASR without a key, or credits while signed out.
|
||||
case unavailable
|
||||
|
||||
var canDictate: Bool { self != .unavailable }
|
||||
|
||||
var isFullyConfigured: Bool { self == .credits || self == .ownKey }
|
||||
}
|
||||
|
||||
/// Account session reduced to what Home needs.
|
||||
enum HomeAccountPhase: Equatable {
|
||||
case restoring
|
||||
case signedOut
|
||||
case signedIn
|
||||
}
|
||||
|
||||
/// Credit snapshot reduced to what Home needs.
|
||||
enum HomeCreditsState: Equatable {
|
||||
/// Not loaded yet. Treated as healthy so Home never flashes an error card.
|
||||
case loading
|
||||
/// No snapshot could be loaded at all (a stale snapshot stays `.balance`).
|
||||
case failed
|
||||
case balance(Int64)
|
||||
}
|
||||
|
||||
enum HomeAccountSurface: Equatable {
|
||||
case permissions
|
||||
case invitation(code: String)
|
||||
case signInSetup
|
||||
case chooseService
|
||||
case creditsUnavailable
|
||||
case creditsDepleted
|
||||
case signInPromo
|
||||
case accountSummary
|
||||
}
|
||||
|
||||
enum HomeAccountSurfacePolicy {
|
||||
static func readiness(
|
||||
credentialSource: CredentialSource,
|
||||
account: HomeAccountPhase,
|
||||
isASRConfigured: Bool,
|
||||
isPolishConfigured: Bool
|
||||
) -> HomeServiceReadiness {
|
||||
switch credentialSource {
|
||||
case .managed:
|
||||
// Restoring keeps credits: a valid session is usually seconds away.
|
||||
return account == .signedOut ? .unavailable : .credits
|
||||
case .byok:
|
||||
guard isASRConfigured else { return .unavailable }
|
||||
return isPolishConfigured ? .ownKey : .dictationOnly
|
||||
}
|
||||
}
|
||||
|
||||
/// First matching rule wins. `nil` means no account surface at all.
|
||||
static func surface(
|
||||
needsPermissionSetup: Bool,
|
||||
account: HomeAccountPhase,
|
||||
pendingReferralCode: String?,
|
||||
isInvitationDeferred: Bool,
|
||||
credentialSource: CredentialSource,
|
||||
readiness: HomeServiceReadiness,
|
||||
credits: HomeCreditsState
|
||||
) -> HomeAccountSurface? {
|
||||
if needsPermissionSetup {
|
||||
return .permissions
|
||||
}
|
||||
switch account {
|
||||
case .restoring:
|
||||
// Avoid showing a sign-in card that a restored session replaces.
|
||||
return nil
|
||||
case .signedOut:
|
||||
if let pendingReferralCode, !isInvitationDeferred {
|
||||
return .invitation(code: pendingReferralCode)
|
||||
}
|
||||
return readiness.isFullyConfigured ? .signInPromo : .signInSetup
|
||||
case .signedIn:
|
||||
guard credentialSource == .managed else {
|
||||
return readiness.isFullyConfigured ? .accountSummary : .chooseService
|
||||
}
|
||||
switch credits {
|
||||
case .failed:
|
||||
return .creditsUnavailable
|
||||
case .balance(let remaining) where remaining <= 0:
|
||||
return .creditsDepleted
|
||||
case .loading, .balance:
|
||||
return .accountSummary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HomeLayoutPolicy {
|
||||
/// An unfolded iPhone Duo can report a compact size class while offering
|
||||
/// iPad-mini-like width, so Home also switches on its own measured width.
|
||||
static let wideMinimumWidth: CGFloat = 600
|
||||
|
||||
static func usesWideLayout(isRegularWidth: Bool, containerWidth: CGFloat) -> Bool {
|
||||
isRegularWidth || containerWidth >= wideMinimumWidth
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// HomeAccountSurfaceViews.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Views for `HomeAccountSurfacePolicy`: one problem card or one quiet sign-in
|
||||
// row above the metrics. Navigation stays with `HomeView`.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
/// Navigation the problem card cannot perform on its own.
|
||||
struct HomeAccountSurfaceActions {
|
||||
var requestPermissions: () -> Void
|
||||
var openOwnKeySettings: () -> Void
|
||||
var openAccount: () -> Void
|
||||
var deferInvitation: () -> Void
|
||||
}
|
||||
|
||||
struct HomeAccountProblemCard: View {
|
||||
private enum Tone {
|
||||
case good
|
||||
case warning
|
||||
case danger
|
||||
}
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
let surface: HomeAccountSurface
|
||||
let readiness: HomeServiceReadiness
|
||||
/// Short screens (a folded iPhone Duo, iPhone SE) drop footnotes and tighten spacing.
|
||||
let compact: Bool
|
||||
let actions: HomeAccountSurfaceActions
|
||||
|
||||
@State private var isCodeEntryPresented = false
|
||||
@State private var showsManagedCloudConsent = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: compact ? Spacing.sm : Spacing.md) {
|
||||
content
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.surfaceCard()
|
||||
.sheet(isPresented: $isCodeEntryPresented) {
|
||||
ReferralCodeEntrySheet()
|
||||
}
|
||||
.managedCloudConsentAlert(
|
||||
isPresented: $showsManagedCloudConsent,
|
||||
config: config,
|
||||
onAccept: activateCredits
|
||||
)
|
||||
.accessibilityIdentifier("home.account.problemCard")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch surface {
|
||||
case .permissions:
|
||||
permissionsContent
|
||||
case .invitation(let code):
|
||||
invitationContent(code: code)
|
||||
case .signInSetup:
|
||||
signInSetupContent
|
||||
case .chooseService:
|
||||
chooseServiceContent
|
||||
case .creditsDepleted:
|
||||
creditsDepletedContent
|
||||
case .creditsUnavailable:
|
||||
creditsUnavailableContent
|
||||
case .signInPromo, .accountSummary:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - States
|
||||
|
||||
private var permissionsContent: some View {
|
||||
Group {
|
||||
heading(
|
||||
statusKey: "home.account.status.setupRequired",
|
||||
tone: .warning,
|
||||
titleKey: "home.account.permissions.title",
|
||||
body: AppPermissions.homePermissionGuidanceMessage
|
||||
)
|
||||
primaryButton(
|
||||
AppPermissions.canRequestPermissionsInApp
|
||||
? "home.account.permissions.continue"
|
||||
: "home.flow.openSettings",
|
||||
action: actions.requestPermissions
|
||||
)
|
||||
if AppPermissions.canRequestPermissionsInApp {
|
||||
footnote("home.account.permissions.footnote")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func invitationContent(code: String) -> some View {
|
||||
Group {
|
||||
heading(
|
||||
statusKey: "home.account.status.invitation",
|
||||
tone: .good,
|
||||
titleKey: "home.account.invitation.title",
|
||||
body: AppL10n.string("home.account.invitation.body")
|
||||
)
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(AppL10n.format(
|
||||
"home.account.invitation.code",
|
||||
ReferralShortCode.display(code)
|
||||
))
|
||||
.font(TypeStyle.bodyEmph.monospaced())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
textLink("home.account.invitation.change") {
|
||||
isCodeEntryPresented = true
|
||||
}
|
||||
}
|
||||
appleSignInButton
|
||||
centeredLinks {
|
||||
textLink("home.account.invitation.later", muted: true, action: actions.deferInvitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var signInSetupContent: some View {
|
||||
Group {
|
||||
heading(
|
||||
statusKey: readiness.canDictate
|
||||
? "home.account.status.dictationReady"
|
||||
: "home.account.status.setupRequired",
|
||||
tone: readiness.canDictate ? .good : .warning,
|
||||
titleKey: readiness.canDictate
|
||||
? "home.account.setup.title.polish"
|
||||
: "home.account.setup.title.cloud",
|
||||
body: AppL10n.string(
|
||||
readiness.canDictate
|
||||
? "home.account.setup.body.polish"
|
||||
: "home.account.setup.body.cloud"
|
||||
)
|
||||
)
|
||||
appleSignInButton
|
||||
centeredLinks {
|
||||
textLink("home.account.ownKey", action: actions.openOwnKeySettings)
|
||||
textLink("home.account.haveInvitation") {
|
||||
isCodeEntryPresented = true
|
||||
}
|
||||
}
|
||||
if readiness.canDictate {
|
||||
footnote("home.account.setup.footnote")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chooseServiceContent: some View {
|
||||
Group {
|
||||
heading(
|
||||
statusKey: "home.account.status.signedIn",
|
||||
tone: .good,
|
||||
titleKey: "home.account.choose.title",
|
||||
body: chooseServiceBody
|
||||
)
|
||||
primaryButton(
|
||||
"home.account.useCredits",
|
||||
isBusy: accountSession.operation == .preparingManagedGateway,
|
||||
action: useCredits
|
||||
)
|
||||
centeredLinks {
|
||||
textLink("home.account.ownKey", action: actions.openOwnKeySettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var creditsDepletedContent: some View {
|
||||
Group {
|
||||
heading(
|
||||
statusKey: "home.account.status.paused",
|
||||
tone: .warning,
|
||||
titleKey: "home.account.depleted.title",
|
||||
body: AppL10n.string("home.account.depleted.body")
|
||||
)
|
||||
primaryButton("home.account.depleted.add", action: actions.openAccount)
|
||||
centeredLinks {
|
||||
textLink("home.account.ownKey", action: actions.openOwnKeySettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var creditsUnavailableContent: some View {
|
||||
Group {
|
||||
heading(
|
||||
statusKey: "home.account.status.connection",
|
||||
tone: .danger,
|
||||
titleKey: "home.account.unavailable.title",
|
||||
body: AppL10n.string("home.account.unavailable.body")
|
||||
)
|
||||
primaryButton(
|
||||
"account.retry",
|
||||
isBusy: accountSession.isRefreshingAccountData
|
||||
) {
|
||||
Task { await accountSession.refreshAccountData(force: true) }
|
||||
}
|
||||
centeredLinks {
|
||||
textLink("home.account.ownKey", action: actions.openOwnKeySettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chooseServiceBody: String {
|
||||
guard case let .loaded(snapshot) = accountSession.snapshotPhase else {
|
||||
return AppL10n.string("home.account.choose.bodyLoading")
|
||||
}
|
||||
return AppL10n.format(
|
||||
"home.account.choose.body",
|
||||
max(snapshot.credits.balance, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Credits activation
|
||||
|
||||
private func useCredits() {
|
||||
if config.hasAcknowledgedCloudSharing {
|
||||
activateCredits()
|
||||
} else {
|
||||
showsManagedCloudConsent = true
|
||||
}
|
||||
}
|
||||
|
||||
private func activateCredits() {
|
||||
Task {
|
||||
await ManagedServiceActivation.activate(
|
||||
config: config,
|
||||
accountSession: accountSession
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Building blocks
|
||||
|
||||
private func heading(
|
||||
statusKey: String,
|
||||
tone: Tone,
|
||||
titleKey: String,
|
||||
body: String
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Circle()
|
||||
.fill(color(for: tone))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(AppL10n.string(statusKey))
|
||||
.font(TypeStyle.caption2.weight(.semibold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
|
||||
Text(AppL10n.string(titleKey))
|
||||
.font(compact ? TypeStyle.headline : TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(body)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(compact ? 3 : nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var appleSignInButton: some View {
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(accountSession.operation != nil)
|
||||
}
|
||||
|
||||
private func primaryButton(
|
||||
_ titleKey: String,
|
||||
isBusy: Bool = false,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Group {
|
||||
if isBusy {
|
||||
ProgressView()
|
||||
.tint(palette.textOnAccent)
|
||||
} else {
|
||||
Text(AppL10n.string(titleKey))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(
|
||||
palette.accent,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy || accountSession.operation != nil)
|
||||
}
|
||||
|
||||
private func textLink(
|
||||
_ titleKey: String,
|
||||
muted: Bool = false,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Text(AppL10n.string(titleKey))
|
||||
.font(TypeStyle.footnote.weight(muted ? .medium : .semibold))
|
||||
.foregroundStyle(muted ? palette.textTertiary : palette.accent)
|
||||
.padding(.vertical, Spacing.xxs)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func centeredLinks<Links: View>(@ViewBuilder _ links: () -> Links) -> some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
links()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func footnote(_ key: String) -> some View {
|
||||
if !compact {
|
||||
Text(AppL10n.string(key))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for tone: Tone) -> Color {
|
||||
switch tone {
|
||||
case .good: return palette.accent
|
||||
case .warning: return palette.warning
|
||||
case .danger: return palette.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed out with a working BYOK setup: the product already works, so the
|
||||
/// credits offer is a single quiet row rather than a card.
|
||||
struct HomeSignInPromoRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let onOpenAccount: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onOpenAccount) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(AppL10n.string("home.account.promo.title"))
|
||||
.font(TypeStyle.footnote.weight(.semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(AppL10n.string("home.account.promo.body"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.surfaceCard()
|
||||
.accessibilityIdentifier("home.account.signInPromo")
|
||||
}
|
||||
}
|
||||
+141
-159
@@ -1,9 +1,10 @@
|
||||
// HomeView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Home: logo, transient Flow connection status, usage stats, account rewards,
|
||||
// and dictionary suggestions. History/dictionary/account destinations open
|
||||
// via push (system back) rather than bottom-tab destinations.
|
||||
// Home: logo, Flow session status capsule, at most one account surface
|
||||
// (`HomeAccountSurfacePolicy`), usage stats, and dictionary suggestions.
|
||||
// History/dictionary/account destinations open via push (system back) rather
|
||||
// than bottom-tab destinations.
|
||||
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
@@ -76,40 +77,9 @@ enum FlowHomePiPStatusPolicy {
|
||||
guard case .failed = lifecycle else { return false }
|
||||
return !needsPermissionSetup
|
||||
}
|
||||
|
||||
static func shouldShowConnectionCard(
|
||||
lifecycle: FlowPiPLifecycleState,
|
||||
needsPermissionSetup: Bool,
|
||||
needsAPIKeySetup: Bool,
|
||||
hasSessionWarning: Bool,
|
||||
isRecording: Bool,
|
||||
isProcessing: Bool,
|
||||
isHostReady: Bool
|
||||
) -> Bool {
|
||||
if needsPermissionSetup || needsAPIKeySetup || hasSessionWarning {
|
||||
return true
|
||||
}
|
||||
if isRecording || isProcessing {
|
||||
return false
|
||||
}
|
||||
if case .active = lifecycle {
|
||||
return !isHostReady
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
enum HomeServiceSetupPolicy {
|
||||
static func apiKeyMessageKey(
|
||||
isLocalEngine: Bool,
|
||||
isASRConfigured: Bool
|
||||
) -> String {
|
||||
if isLocalEngine || isASRConfigured {
|
||||
return "home.setup.polishKeyMissing"
|
||||
}
|
||||
return "home.setup.cloudIncomplete"
|
||||
}
|
||||
|
||||
static func apiKeyDeepLink(
|
||||
isLocalEngine: Bool,
|
||||
isASRConfigured: Bool
|
||||
@@ -126,39 +96,70 @@ struct HomeView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.wideTopCornerClearance) private var topCornerClearance
|
||||
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
@State private var path = NavigationPath()
|
||||
@State private var dictionarySuggestions: [FrequentTerm] = []
|
||||
@State private var pendingDictionarySuggestion: FrequentTerm?
|
||||
@State private var containerWidth: CGFloat = 0
|
||||
/// "Not now" on the invitation card lasts for this launch; the code itself
|
||||
/// stays saved and is still redeemed on the next sign-in.
|
||||
@State private var isInvitationDeferred = false
|
||||
|
||||
private let appGroupStore = AppGroupStore()
|
||||
private let dictionaryEntryService = PersonalDictionaryEntryService()
|
||||
private let frequentTermStore = FrequentTermStore()
|
||||
|
||||
private var usesWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
HomeLayoutPolicy.usesWideLayout(
|
||||
isRegularWidth: horizontalSizeClass == .regular,
|
||||
containerWidth: containerWidth
|
||||
)
|
||||
}
|
||||
|
||||
private var sessionIsLive: Bool {
|
||||
flowManager.isActive || flowManager.isStarting
|
||||
}
|
||||
|
||||
/// Cloud: ASR + polish keys. Local: polish LLM key (ASR is on-device).
|
||||
private var needsAPIKeySetup: Bool {
|
||||
if config.isLocalEngine {
|
||||
return !config.isPolishConfigured
|
||||
private var accountPhase: HomeAccountPhase {
|
||||
switch accountSession.sessionPhase {
|
||||
case .restoring: return .restoring
|
||||
case .signedOut: return .signedOut
|
||||
case .signedIn: return .signedIn
|
||||
}
|
||||
return !config.isConfigured
|
||||
}
|
||||
|
||||
private var apiKeySetupMessageKey: String {
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: config.isLocalEngine,
|
||||
isASRConfigured: config.isASRConfigured
|
||||
private var serviceReadiness: HomeServiceReadiness {
|
||||
HomeAccountSurfacePolicy.readiness(
|
||||
credentialSource: config.credentialSource,
|
||||
account: accountPhase,
|
||||
isASRConfigured: config.isASRConfigured,
|
||||
isPolishConfigured: config.isPolishConfigured
|
||||
)
|
||||
}
|
||||
|
||||
private var creditsState: HomeCreditsState {
|
||||
switch accountSession.snapshotPhase {
|
||||
case .idle, .loading: return .loading
|
||||
case .failed: return .failed
|
||||
case .loaded(let snapshot): return .balance(snapshot.credits.balance)
|
||||
}
|
||||
}
|
||||
|
||||
private var accountSurface: HomeAccountSurface? {
|
||||
HomeAccountSurfacePolicy.surface(
|
||||
needsPermissionSetup: needsPermissionSetup,
|
||||
account: accountPhase,
|
||||
pendingReferralCode: accountSession.pendingReferralCode,
|
||||
isInvitationDeferred: isInvitationDeferred,
|
||||
credentialSource: config.credentialSource,
|
||||
readiness: serviceReadiness,
|
||||
credits: creditsState
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,10 +170,22 @@ struct HomeView: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// Credits users first switch the service source; BYOK users go straight
|
||||
/// to the missing key.
|
||||
private var ownKeySettingsDeepLink: SettingsDeepLink {
|
||||
config.credentialSource == .managed ? .aiService : apiKeySettingsDeepLink
|
||||
}
|
||||
|
||||
private var needsPermissionSetup: Bool {
|
||||
micStatus != .granted || speechStatus != .granted
|
||||
}
|
||||
|
||||
/// The permission card already explains why voice input cannot start, and
|
||||
/// the split layout's footer already reports the session on every tab.
|
||||
private var showsStatusCapsule: Bool {
|
||||
!needsPermissionSetup && horizontalSizeClass != .regular
|
||||
}
|
||||
|
||||
/// Can the user start a session right now from the Home footer? Only when
|
||||
/// nothing is live/starting and permissions are already granted (otherwise
|
||||
/// the permission guidance card is the correct call to action).
|
||||
@@ -197,20 +210,6 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Healthy sessions need no persistent chrome. Keep the connection card
|
||||
/// only for setup, startup, recovery, or a genuinely unavailable session.
|
||||
private var showsFlowConnectionCard: Bool {
|
||||
FlowHomePiPStatusPolicy.shouldShowConnectionCard(
|
||||
lifecycle: flowManager.pipLifecycleState,
|
||||
needsPermissionSetup: needsPermissionSetup,
|
||||
needsAPIKeySetup: needsAPIKeySetup,
|
||||
hasSessionWarning: flowManager.sessionWarning != nil,
|
||||
isRecording: flowManager.isUtteranceRecording,
|
||||
isProcessing: flowManager.isUtteranceProcessing,
|
||||
isHostReady: FlowSessionBridge.isHostReady()
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
Group {
|
||||
@@ -233,6 +232,18 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
|
||||
// Folding or unfolding an iPhone Duo changes this width mid-session.
|
||||
.onGeometryChange(for: CGFloat.self) { proxy in
|
||||
proxy.size.width
|
||||
} action: { width in
|
||||
containerWidth = width
|
||||
}
|
||||
// Home owns the account refresh now that the summary card is not
|
||||
// always on screen; the coordinator's freshness window dedupes calls.
|
||||
.task(id: accountSession.accountID) {
|
||||
guard accountSession.isSignedIn else { return }
|
||||
await accountSession.refreshAccountData()
|
||||
}
|
||||
.onAppear {
|
||||
refreshPermissionStatuses()
|
||||
refreshDictionarySuggestions()
|
||||
@@ -241,6 +252,9 @@ struct HomeView: View {
|
||||
guard phase == .active else { return }
|
||||
refreshPermissionStatuses()
|
||||
refreshDictionarySuggestions()
|
||||
if accountSession.isSignedIn {
|
||||
Task { await accountSession.refreshAccountData() }
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshPermissionStatuses()
|
||||
@@ -273,14 +287,12 @@ struct HomeView: View {
|
||||
VStack(spacing: 0) {
|
||||
logoHeader(compact: isCompact)
|
||||
.padding(.top, logoTopPadding)
|
||||
.padding(.bottom, logoBottomPadding)
|
||||
.padding(.bottom, showsStatusCapsule ? Spacing.md : logoBottomPadding)
|
||||
|
||||
// Connection status is transient: once Flow is ready,
|
||||
// content moves up and the card disappears completely.
|
||||
if showsFlowConnectionCard {
|
||||
scrollStatusFooter
|
||||
if showsStatusCapsule {
|
||||
flowStatusCapsule
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, CardLayoutMetrics.sectionSpacing)
|
||||
.padding(.bottom, logoBottomPadding)
|
||||
}
|
||||
|
||||
homeContentSections(layout: .stacked, compact: isCompact)
|
||||
@@ -295,31 +307,17 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact Flow connection status — no engine/model details.
|
||||
private var scrollStatusFooter: some View {
|
||||
setupGuidanceCard {
|
||||
flowStatusFooter
|
||||
if needsAPIKeySetup {
|
||||
apiKeySetupGuidance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wide layout (iPad / regular width)
|
||||
// MARK: - Wide layout (iPad / unfolded iPhone Duo)
|
||||
|
||||
private var wideBody: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: CardLayoutMetrics.sectionSpacing) {
|
||||
wideHeroHeader
|
||||
|
||||
if showsFlowConnectionCard {
|
||||
scrollStatusFooter
|
||||
}
|
||||
|
||||
homeContentSections(layout: .split)
|
||||
}
|
||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.top, Spacing.sm + topCornerClearance)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
@@ -337,14 +335,20 @@ struct HomeView: View {
|
||||
Text(AppL10n.string("home.wide.tagline.subtitle"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
if showsStatusCapsule {
|
||||
flowStatusCapsule
|
||||
.padding(.top, Spacing.xs)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Usage, account, and dictionary
|
||||
|
||||
/// Keep the Home information hierarchy stable on both phone and iPad:
|
||||
/// account rewards → four metrics → calendar → personal dictionary.
|
||||
/// Keep the Home information hierarchy stable on phone, iPhone Duo, and
|
||||
/// iPad: account surface (problem card or summary, never both) → four
|
||||
/// metrics → calendar → personal dictionary.
|
||||
private func homeContentSections(
|
||||
layout: UsageStatsClusterLayout,
|
||||
compact: Bool = false
|
||||
@@ -369,8 +373,25 @@ struct HomeView: View {
|
||||
layout: UsageStatsClusterLayout,
|
||||
compact: Bool
|
||||
) -> some View {
|
||||
AccountRewardsCard {
|
||||
path.append(HomeRoute.account)
|
||||
switch accountSurface {
|
||||
case .accountSummary:
|
||||
AccountRewardsCard(showsOwnKeyNote: config.credentialSource == .byok) {
|
||||
path.append(HomeRoute.account)
|
||||
}
|
||||
case .signInPromo:
|
||||
HomeSignInPromoRow {
|
||||
path.append(HomeRoute.account)
|
||||
}
|
||||
case let surface?:
|
||||
HomeAccountProblemCard(
|
||||
surface: surface,
|
||||
readiness: serviceReadiness,
|
||||
compact: compact,
|
||||
actions: accountSurfaceActions
|
||||
)
|
||||
.transition(.opacity)
|
||||
case nil:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
HomeUsageStatsSection(
|
||||
@@ -394,6 +415,19 @@ struct HomeView: View {
|
||||
dictionaryCard
|
||||
}
|
||||
|
||||
private var accountSurfaceActions: HomeAccountSurfaceActions {
|
||||
HomeAccountSurfaceActions(
|
||||
requestPermissions: handlePermissionGuidanceAction,
|
||||
openOwnKeySettings: { openSettings(ownKeySettingsDeepLink) },
|
||||
openAccount: { path.append(HomeRoute.account) },
|
||||
deferInvitation: {
|
||||
withAnimation(Motion.soft) {
|
||||
isInvitationDeferred = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var dictionaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Button {
|
||||
@@ -594,8 +628,8 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var headerGradientColors: [Color] {
|
||||
// 云端引擎未配置(缺 API Key)时不算就绪,保持中性灰渐变。
|
||||
if sessionIsLive, !needsAPIKeySetup {
|
||||
// 云端识别没有可用的 API Key 或积分时不算就绪,保持中性灰渐变。
|
||||
if sessionIsLive, serviceReadiness.canDictate {
|
||||
let leadingOpacity = colorScheme == .dark ? 0.18 : 0.28
|
||||
let trailingOpacity = colorScheme == .dark ? 0.06 : 0.10
|
||||
return [
|
||||
@@ -634,14 +668,15 @@ struct HomeView: View {
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
}
|
||||
|
||||
// 就绪信息:绿点 + 状态文字(+ 计时 / 结束文本按钮),字号对齐引擎信息行。
|
||||
private var flowStatusFooter: some View {
|
||||
// 会话状态胶囊:圆点 + 状态文字(就绪时附服务来源与计时)+ 开启 / 结束 / 重试。
|
||||
// 设置类问题由账号问题卡负责,这里只报告语音会话本身。
|
||||
private var flowStatusCapsule: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Circle()
|
||||
.fill(flowStatusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
if needsPermissionSetup || needsAPIKeySetup {
|
||||
if !serviceReadiness.canDictate {
|
||||
Text(AppL10n.string("home.flow.notReady"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.warning)
|
||||
@@ -663,9 +698,13 @@ struct HomeView: View {
|
||||
Text(AppL10n.string("home.flow.label"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(":")
|
||||
Text("·")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(AppL10n.string(serviceSourceLabelKey))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
Text(expires, style: .timer)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
@@ -678,21 +717,8 @@ struct HomeView: View {
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
|
||||
if needsPermissionSetup {
|
||||
Button(action: handlePermissionGuidanceAction) {
|
||||
Text(
|
||||
AppPermissions.canRequestPermissionsInApp
|
||||
? "home.setup.permission.request"
|
||||
: "home.flow.openSettings"
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, Spacing.xs)
|
||||
} else if needsAPIKeySetup {
|
||||
// The explanatory copy and both setup paths sit below this
|
||||
// compact status row so they remain readable on narrow phones.
|
||||
if !serviceReadiness.canDictate {
|
||||
// The account card offers the setup paths.
|
||||
EmptyView()
|
||||
} else if canRetryPiP {
|
||||
Button {
|
||||
@@ -740,73 +766,29 @@ struct HomeView: View {
|
||||
.padding(.leading, Spacing.xs)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 6)
|
||||
.background(palette.textPrimary.opacity(0.06), in: Capsule())
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
.accessibilityIdentifier("home.flow.statusCapsule")
|
||||
}
|
||||
|
||||
private var apiKeySetupGuidance: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(AppL10n.string(apiKeySetupMessageKey))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(AppL10n.string("home.setup.credits.alternative"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Button {
|
||||
openSettings(apiKeySettingsDeepLink)
|
||||
} label: {
|
||||
Label(AppL10n.string("home.setup.byok.configure"), systemImage: "key")
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 34)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
openSettings(.aiService)
|
||||
} label: {
|
||||
Label(AppL10n.string("home.setup.credits.open"), systemImage: "sparkles")
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 34)
|
||||
.background(
|
||||
palette.accent,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
private var serviceSourceLabelKey: String {
|
||||
switch serviceReadiness {
|
||||
case .credits: return "home.flow.source.credits"
|
||||
case .ownKey: return "home.flow.source.ownKey"
|
||||
case .dictationOnly, .unavailable: return "home.flow.source.dictationOnly"
|
||||
}
|
||||
}
|
||||
|
||||
private func setupGuidanceCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.cardElevation()
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
if needsAPIKeySetup { return palette.warning }
|
||||
if !serviceReadiness.canDictate { return palette.warning }
|
||||
if flowManager.isUtteranceRecording { return palette.accent }
|
||||
if flowManager.isUtteranceProcessing { return palette.accent }
|
||||
if case .failed = flowManager.pipLifecycleState { return palette.warning }
|
||||
if case .waitingForForeground = flowManager.pipLifecycleState { return palette.warning }
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
|
||||
if flowManager.isStarting { return palette.accent }
|
||||
if needsPermissionSetup { return palette.warning }
|
||||
if flowManager.sessionWarning != nil { return palette.warning }
|
||||
// Active but not host-ready (e.g. mid-utterance / audio proof) — amber.
|
||||
if flowManager.isActive { return palette.warning }
|
||||
|
||||
@@ -97,9 +97,7 @@ struct MainAppRoot: View {
|
||||
AppL10n.string("account.error.title"),
|
||||
isPresented: accountOperationErrorBinding
|
||||
) {
|
||||
Button(AppL10n.string("common.done")) {
|
||||
accountSession.dismissOperationError()
|
||||
}
|
||||
Button(AppL10n.string("common.done")) {}
|
||||
} message: {
|
||||
if let key = accountSession.operationErrorKey {
|
||||
Text(AppL10n.string(key))
|
||||
@@ -326,7 +324,11 @@ struct MainAppRoot: View {
|
||||
get: { accountSession.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
accountSession.dismissOperationError()
|
||||
// SwiftUI writes this binding while updating the alert.
|
||||
// Defer ObservableObject mutations until that update ends.
|
||||
Task { @MainActor in
|
||||
accountSession.dismissOperationError()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -13,13 +13,15 @@ struct MainSplitView: View {
|
||||
@Binding var selection: AppTab
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@Environment(\.wideTopCornerClearance) private var topCornerClearance
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
// Sidebar is always visible: once collapsed, iPhone Duo offers no
|
||||
// control to bring it back.
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebar
|
||||
.navigationSplitViewColumnWidth(WideLayoutMetrics.sidebarWidth)
|
||||
.toolbar(removing: .sidebarToggle)
|
||||
} detail: {
|
||||
detail
|
||||
}
|
||||
@@ -46,55 +48,44 @@ struct MainSplitView: View {
|
||||
}
|
||||
.padding(.horizontal, WideLayoutMetrics.sidebarInset)
|
||||
Spacer()
|
||||
devicesFooter
|
||||
WideSidebarStatusBar()
|
||||
.padding(.bottom, Spacing.xxs)
|
||||
}
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private var brandHeader: some View {
|
||||
HStack {
|
||||
// Match macOS sidebar: template wide mark tinted with accent so
|
||||
// light / dark both stay readable (PNG `osglogo` can fail to
|
||||
// show under NavigationSplitView chrome on some iPad sizes).
|
||||
// Match macOS sidebar: template wide mark tinted with the primary
|
||||
// text color so light / dark both stay readable (PNG `osglogo` can
|
||||
// fail to show under NavigationSplitView chrome on some iPad sizes).
|
||||
Image("OSGLogoWide")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 28, alignment: .leading)
|
||||
.padding(.leading, WideLayoutMetrics.sidebarContentInset)
|
||||
.padding(.trailing, WideLayoutMetrics.sidebarInset)
|
||||
.padding(.top, Spacing.lg)
|
||||
.padding(.top, Spacing.lg + topCornerClearance)
|
||||
// Roughly double the gap between the brand header and the first
|
||||
// sidebar row on iPad so the logo gets visual breathing room above
|
||||
// the menu list (was `Spacing.md`, now `Spacing.xxxl`).
|
||||
.padding(.bottom, Spacing.xxxl)
|
||||
}
|
||||
|
||||
private var devicesFooter: some View {
|
||||
Label(AppL10n.string("home.wide.devices"), systemImage: "ipad.and.iphone")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, WideLayoutMetrics.sidebarInset + Spacing.sm)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
// MARK: - Detail
|
||||
|
||||
private var detail: some View {
|
||||
VStack(spacing: 0) {
|
||||
MainTabContent(tab: selection)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.id(selection)
|
||||
.transition(.opacity)
|
||||
WideStatusFooter()
|
||||
}
|
||||
.background(palette.background)
|
||||
MainTabContent(tab: selection)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.id(selection)
|
||||
.transition(.opacity)
|
||||
.background(palette.background)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +138,7 @@ private struct WideSidebarRow: View {
|
||||
/// automatically; this hand-rolled `VStack` sidebar has to do it itself.
|
||||
private struct SidebarIconColumnLabelStyle: LabelStyle {
|
||||
/// Wider than the widest symbol in `AppTab.sidebarSystemImage(selected:)`.
|
||||
private static let iconColumnWidth: CGFloat = 22
|
||||
static let iconColumnWidth: CGFloat = 22
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
@@ -158,22 +149,31 @@ private struct SidebarIconColumnLabelStyle: LabelStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status footer
|
||||
// MARK: - Sidebar status
|
||||
|
||||
/// Sidebar bottom toolbar: Flow session toggle, engine, and translation as
|
||||
/// icon buttons, with the session state as a single trailing word.
|
||||
private struct WideSidebarStatusBar: View {
|
||||
/// 36pt hit target per button; the icon glyph stays at sidebar size.
|
||||
static let buttonSize: CGFloat = 36
|
||||
|
||||
/// Quiet bottom strip: engine mode + translation target + Flow readiness.
|
||||
private struct WideStatusFooter: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
|
||||
/// Same readiness rule as Home, so the footer and the Home account card
|
||||
/// never disagree about whether dictation works.
|
||||
private var needsAPIKeySetup: Bool {
|
||||
if config.isLocalEngine {
|
||||
return !config.isPolishConfigured
|
||||
}
|
||||
return !config.isConfigured
|
||||
!HomeAccountSurfacePolicy.readiness(
|
||||
credentialSource: config.credentialSource,
|
||||
account: accountSession.sessionPhase == .signedOut ? .signedOut : .signedIn,
|
||||
isASRConfigured: config.isASRConfigured,
|
||||
isPolishConfigured: config.isPolishConfigured
|
||||
).canDictate
|
||||
}
|
||||
|
||||
private var needsPermissionSetup: Bool {
|
||||
@@ -184,34 +184,42 @@ private struct WideStatusFooter: View {
|
||||
!flowManager.isActive && !flowManager.isStarting && !needsPermissionSetup
|
||||
}
|
||||
|
||||
private var isCloudEngine: Bool {
|
||||
config.engineMode == "cloud"
|
||||
}
|
||||
|
||||
private var isTranslating: Bool {
|
||||
!TranslationLanguageCatalog.isOff(config.translationTargetLocaleId)
|
||||
}
|
||||
|
||||
/// Centers the first button on the sidebar menu's icon column.
|
||||
private var leadingInset: CGFloat {
|
||||
WideLayoutMetrics.sidebarContentInset
|
||||
+ (SidebarIconColumnLabelStyle.iconColumnWidth - Self.buttonSize) / 2
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Spacer()
|
||||
Label(
|
||||
config.engineMode == "cloud" ? "home.wide.mode.cloud" : "home.wide.mode.local",
|
||||
systemImage: config.engineMode == "cloud" ? "cloud" : "cpu"
|
||||
)
|
||||
.contentTransition(.opacity)
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Divider()
|
||||
.overlay(palette.divider)
|
||||
.padding(.horizontal, WideLayoutMetrics.sidebarInset)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
HStack(spacing: Spacing.xxs) {
|
||||
sessionButton
|
||||
engineButton
|
||||
translationButton
|
||||
|
||||
Label(
|
||||
translationLabel,
|
||||
systemImage: "translate"
|
||||
)
|
||||
.contentTransition(.opacity)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
flowStatusControl
|
||||
sessionStatusText
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(flowStatusColor)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.padding(.leading, leadingInset)
|
||||
.padding(.trailing, WideLayoutMetrics.sidebarContentInset)
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.animation(Motion.quick, value: config.engineMode)
|
||||
.animation(Motion.quick, value: config.translationTargetLocaleId)
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
@@ -221,52 +229,166 @@ private struct WideStatusFooter: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var flowStatusControl: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Circle()
|
||||
.fill(flowStatusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
// MARK: Session
|
||||
|
||||
if needsAPIKeySetup {
|
||||
Text(AppL10n.string("home.flow.notReady"))
|
||||
.foregroundStyle(palette.warning)
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
Text(AppL10n.string("home.flow.recording"))
|
||||
} else if flowManager.isUtteranceProcessing {
|
||||
Text(AppL10n.string("home.flow.processing"))
|
||||
} else if flowManager.isActive,
|
||||
FlowSessionBridge.isHostReady(),
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text(AppL10n.string("home.flow.label"))
|
||||
Text(":")
|
||||
Text(expires, style: .timer)
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text(AppL10n.string(flowStatusLabel))
|
||||
}
|
||||
|
||||
if flowManager.isActive {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
Text(AppL10n.string("home.flow.endShort"))
|
||||
.foregroundStyle(palette.accent)
|
||||
/// Starts or ends the session; when the service isn't set up it opens
|
||||
/// Settings instead, since starting would only fail.
|
||||
private var sessionButton: some View {
|
||||
Button(action: handleSessionTap) {
|
||||
toolbarIcon(
|
||||
flowManager.isActive ? "mic.fill" : "mic",
|
||||
tint: sessionIconTint,
|
||||
isHighlighted: flowManager.isActive
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
// An idle session needs no badge; the grey glyph says enough.
|
||||
if showsStatusDot {
|
||||
Circle()
|
||||
.fill(flowStatusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.padding(Spacing.xs)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if canManuallyStartSession && !needsAPIKeySetup {
|
||||
Button {
|
||||
flowManager.activateOnForeground(
|
||||
reason: "MainSplitView.startButton",
|
||||
startCapture: true
|
||||
)
|
||||
} label: {
|
||||
Text(AppL10n.string("home.flow.startShort"))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canTapSession)
|
||||
.help(AppL10n.string(sessionActionKey))
|
||||
.accessibilityLabel(AppL10n.string(sessionActionKey))
|
||||
}
|
||||
|
||||
private var sessionIconTint: Color {
|
||||
if needsAPIKeySetup { return palette.warning }
|
||||
return flowManager.isActive ? palette.accent : palette.textSecondary
|
||||
}
|
||||
|
||||
private var showsStatusDot: Bool {
|
||||
needsAPIKeySetup || flowManager.isActive || flowManager.isStarting
|
||||
}
|
||||
|
||||
private var canTapSession: Bool {
|
||||
flowManager.isActive || needsAPIKeySetup || canManuallyStartSession
|
||||
}
|
||||
|
||||
private var sessionActionKey: String {
|
||||
if flowManager.isActive { return "home.flow.end" }
|
||||
if needsAPIKeySetup { return "home.wide.status.setUp" }
|
||||
return "home.flow.start"
|
||||
}
|
||||
|
||||
private func handleSessionTap() {
|
||||
if flowManager.isActive {
|
||||
flowManager.endSession()
|
||||
} else if needsAPIKeySetup {
|
||||
openSettings(.aiService)
|
||||
} else if canManuallyStartSession {
|
||||
flowManager.activateOnForeground(
|
||||
reason: "MainSplitView.startButton",
|
||||
startCapture: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sessionStatusText: some View {
|
||||
if needsAPIKeySetup {
|
||||
Text(AppL10n.string("home.flow.notReady"))
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
Text(AppL10n.string("home.flow.recording"))
|
||||
} else if flowManager.isUtteranceProcessing {
|
||||
Text(AppL10n.string("home.flow.processing"))
|
||||
} else if flowManager.isActive,
|
||||
FlowSessionBridge.isHostReady(),
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
HStack(spacing: Spacing.xxs) {
|
||||
Text(AppL10n.string("home.flow.label"))
|
||||
Text(expires, style: .timer)
|
||||
.monospacedDigit()
|
||||
}
|
||||
} else {
|
||||
Text(AppL10n.string(flowStatusLabel))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Engine and translation
|
||||
|
||||
/// Engine changes involve sign-in, credits, and cloud consent, so the
|
||||
/// button opens Settings instead of switching in place.
|
||||
private var engineButton: some View {
|
||||
Button {
|
||||
openSettings(.aiService)
|
||||
} label: {
|
||||
toolbarIcon(isCloudEngine ? "cloud" : "cpu", tint: palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(toolbarHelp(titleKey: "home.wide.status.engine", value: engineLabel))
|
||||
.accessibilityLabel(AppL10n.string("home.wide.status.engine"))
|
||||
.accessibilityValue(engineLabel)
|
||||
}
|
||||
|
||||
private var translationButton: some View {
|
||||
Menu {
|
||||
Picker("", selection: translationSelection) {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Text(translationDisplayLabel(for: language))
|
||||
.tag(language.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
toolbarIcon(
|
||||
"globe",
|
||||
tint: isTranslating ? palette.accent : palette.textSecondary,
|
||||
isHighlighted: isTranslating
|
||||
)
|
||||
}
|
||||
.menuIndicator(.hidden)
|
||||
.buttonStyle(.plain)
|
||||
.help(toolbarHelp(titleKey: "home.wide.status.translation", value: translationLabel))
|
||||
.accessibilityLabel(AppL10n.string("home.wide.status.translation"))
|
||||
.accessibilityValue(translationLabel)
|
||||
}
|
||||
|
||||
private var engineLabel: String {
|
||||
AppL10n.string(isCloudEngine ? "home.wide.mode.cloud" : "home.wide.mode.local")
|
||||
}
|
||||
|
||||
private func toolbarHelp(titleKey: String, value: String) -> String {
|
||||
"\(AppL10n.string(titleKey)) · \(value)"
|
||||
}
|
||||
|
||||
private func toolbarIcon(
|
||||
_ systemImage: String,
|
||||
tint: Color,
|
||||
isHighlighted: Bool = false
|
||||
) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
|
||||
return Image(systemName: systemImage)
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: Self.buttonSize, height: Self.buttonSize)
|
||||
.background(isHighlighted ? palette.accentMuted : .clear, in: shape)
|
||||
.contentShape(shape)
|
||||
.hoverEffect(.highlight)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
|
||||
private func openSettings(_ link: SettingsDeepLink) {
|
||||
SettingsDeepLink.setPending(link)
|
||||
NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil)
|
||||
}
|
||||
|
||||
/// Same single field as Settings ▸ Translation (`TranslationPickerRow`).
|
||||
private var translationSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { config.translationTargetLocaleId },
|
||||
set: { config.translationTargetLocaleId = TranslationLanguageCatalog.resolve($0).id }
|
||||
)
|
||||
}
|
||||
|
||||
private func translationDisplayLabel(for language: TranslationLanguage) -> String {
|
||||
TranslationLanguageCatalog.isOff(language.id)
|
||||
? AppL10n.string("settings.translation.off")
|
||||
: language.nativeName
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
@@ -275,11 +397,7 @@ private struct WideStatusFooter: View {
|
||||
}
|
||||
|
||||
private var translationLabel: String {
|
||||
let resolved = TranslationLanguageCatalog.resolve(config.translationTargetLocaleId)
|
||||
if TranslationLanguageCatalog.isOff(resolved.id) {
|
||||
return AppL10n.string("keyboard.translation.offMenu", language: config.uiLanguage)
|
||||
}
|
||||
return resolved.nativeName
|
||||
translationDisplayLabel(for: TranslationLanguageCatalog.resolve(config.translationTargetLocaleId))
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
|
||||
@@ -21,6 +21,9 @@ struct MainTabView: View {
|
||||
#endif
|
||||
return .keyboard
|
||||
}()
|
||||
/// Window top safe area; wide pages derive their corner clearance from it.
|
||||
/// `nil` until measured, so the first frame does not flash extra padding.
|
||||
@State private var topSafeAreaInset: CGFloat?
|
||||
|
||||
private var usesSplitLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
@@ -38,6 +41,16 @@ struct MainTabView: View {
|
||||
phoneTabLayout
|
||||
}
|
||||
}
|
||||
.environment(
|
||||
\.wideTopCornerClearance,
|
||||
topSafeAreaInset.map { WideLayoutMetrics.topCornerClearance(topSafeAreaInset: $0) } ?? 0
|
||||
)
|
||||
}
|
||||
// Fold, rotation and Split View all move the status bar, so track it live.
|
||||
.onGeometryChange(for: CGFloat.self) { proxy in
|
||||
proxy.safeAreaInsets.top
|
||||
} action: { inset in
|
||||
topSafeAreaInset = inset
|
||||
}
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// ManagedServiceActivation.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Switching to OSG credits always passes the cloud-sharing consent before the
|
||||
// managed gateway is prepared. Settings and Home share this path so neither
|
||||
// can enable credits without the disclosure.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
enum ManagedServiceActivation {
|
||||
/// Prepares the managed gateway, then routes recognition and polish
|
||||
/// through OSG credits. Returns false when the gateway is unavailable.
|
||||
@discardableResult
|
||||
static func activate(
|
||||
config: ProviderConfig,
|
||||
accountSession: AccountSessionCoordinator
|
||||
) async -> Bool {
|
||||
guard await accountSession.prepareManagedGateway() else { return false }
|
||||
withAnimation(Motion.quick) {
|
||||
config.engineMode = "cloud"
|
||||
config.modeId = "polish"
|
||||
config.credentialSource = .managed
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Consent alert shown before the first switch to OSG credits.
|
||||
func managedCloudConsentAlert(
|
||||
isPresented: Binding<Bool>,
|
||||
config: ProviderConfig,
|
||||
onAccept: @escaping () -> Void
|
||||
) -> some View {
|
||||
alert(
|
||||
AppL10n.string("settings.aiService.credits.consent.title"),
|
||||
isPresented: isPresented
|
||||
) {
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
Button(AppL10n.string("settings.aiService.credits.consent.accept")) {
|
||||
config.hasAcknowledgedCloudSharing = true
|
||||
onAccept()
|
||||
}
|
||||
.accessibilityIdentifier("settings.aiService.credits.consent.accept")
|
||||
} message: {
|
||||
Text(AppL10n.string("settings.aiService.credits.consent.message"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,16 +28,32 @@ struct PersonalReplyStyleSection: View {
|
||||
@State private var generationTask: Task<Void, Never>?
|
||||
@State private var generationID: UUID?
|
||||
@State private var editingPack: PolishStylePack?
|
||||
/// Evidence from the generation under review; persisted only on save.
|
||||
@State private var pendingLearning: PendingLearning?
|
||||
@State private var errorAlert: PersonalReplyStyleErrorAlert?
|
||||
|
||||
private struct PendingLearning {
|
||||
let packID: String
|
||||
let prior: PolishStyleLearningPriorEvidence
|
||||
}
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let learningState = PersonalStyleLearningStateStore.shared
|
||||
private let generator: LearnedStyleGenerator
|
||||
|
||||
#if DEBUG
|
||||
/// Concept-film recordings start generation after this delay; `simctl`
|
||||
/// cannot tap the button. Nil keeps the production tap-to-generate flow.
|
||||
static var debugAutoGenerationDelay: Duration?
|
||||
/// Shows the App Store 2,500-character gate instead of the internal one.
|
||||
static var debugUsesReleaseCharacterGate = false
|
||||
#endif
|
||||
|
||||
init(
|
||||
initialEditingPack: PolishStylePack? = nil,
|
||||
generator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language in
|
||||
generator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language, prior in
|
||||
try await PolishStyleLearningService(store: AppGroupStore())
|
||||
.generateStyle(
|
||||
.learnStyle(
|
||||
from: corpus,
|
||||
replyExamples: replyExamples,
|
||||
outputLanguage: language,
|
||||
@@ -46,7 +62,8 @@ struct PersonalReplyStyleSection: View {
|
||||
? PolishStyleLearningCorpusBuilder
|
||||
.testBuildEffectiveCharacterCount
|
||||
: PolishStyleLearningCorpusBuilder
|
||||
.requiredEffectiveCharacterCount
|
||||
.requiredEffectiveCharacterCount,
|
||||
prior: prior
|
||||
)
|
||||
}
|
||||
) {
|
||||
@@ -64,7 +81,7 @@ struct PersonalReplyStyleSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingPack) { pack in
|
||||
.sheet(item: $editingPack, onDismiss: { pendingLearning = nil }) { pack in
|
||||
PolishStyleEditorSheet(
|
||||
pack: pack,
|
||||
isNew: !catalog.entries.contains(where: { $0.id == pack.id })
|
||||
@@ -83,7 +100,15 @@ struct PersonalReplyStyleSection: View {
|
||||
} message: {
|
||||
Text(errorAlert?.message ?? "")
|
||||
}
|
||||
.task { reload() }
|
||||
.task {
|
||||
reload()
|
||||
#if DEBUG
|
||||
if let delay = Self.debugAutoGenerationDelay {
|
||||
try? await Task.sleep(for: delay)
|
||||
generate(from: corpus)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .polishStylesDidSyncFromCloud)) { _ in
|
||||
reload()
|
||||
}
|
||||
@@ -102,13 +127,27 @@ struct PersonalReplyStyleSection: View {
|
||||
/// Debug + TestFlight builds unlock generation at a lower character gate so
|
||||
/// internal testers can exercise the pipeline.
|
||||
private var minimumCharacterCount: Int {
|
||||
AppDistributionChannel.allowsInternalTools
|
||||
#if DEBUG
|
||||
if Self.debugUsesReleaseCharacterGate {
|
||||
return PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
}
|
||||
#endif
|
||||
return AppDistributionChannel.allowsInternalTools
|
||||
? PolishStyleLearningCorpusBuilder.testBuildEffectiveCharacterCount
|
||||
: PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
}
|
||||
|
||||
private func isEligible(_ corpus: PolishStyleLearningCorpus) -> Bool {
|
||||
corpus.effectiveCharacterCount >= minimumCharacterCount
|
||||
/// Without a prior the full corpus gate applies; with one, only the
|
||||
/// characters dictated since the last saved generation count.
|
||||
private func isEligible(
|
||||
_ corpus: PolishStyleLearningCorpus,
|
||||
prior: PolishStyleLearningPriorEvidence? = nil
|
||||
) -> Bool {
|
||||
guard let prior else {
|
||||
return corpus.effectiveCharacterCount >= minimumCharacterCount
|
||||
}
|
||||
return corpus.examples(after: prior.learnedThrough).effectiveCharacterCount
|
||||
>= PolishStyleLearningCorpusBuilder.incrementalEffectiveCharacterCount
|
||||
}
|
||||
|
||||
private var learnedPack: PolishStylePack? {
|
||||
@@ -121,7 +160,8 @@ struct PersonalReplyStyleSection: View {
|
||||
_ pack: PolishStylePack,
|
||||
corpus: PolishStyleLearningCorpus
|
||||
) -> some View {
|
||||
let canRegenerate = isEligible(corpus) && !isGenerating
|
||||
let prior = learningState.prior(for: pack)
|
||||
let canRegenerate = isEligible(corpus, prior: prior) && !isGenerating
|
||||
let actionShape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
@@ -181,6 +221,22 @@ struct PersonalReplyStyleSection: View {
|
||||
Self.confidencePercentage(metadata.confidence)
|
||||
)
|
||||
)
|
||||
if let prior {
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"personalReplyStyle.newSinceLast",
|
||||
Int64(
|
||||
corpus.examples(after: prior.learnedThrough)
|
||||
.effectiveCharacterCount
|
||||
),
|
||||
Int64(
|
||||
PolishStyleLearningCorpusBuilder
|
||||
.incrementalEffectiveCharacterCount
|
||||
)
|
||||
)
|
||||
)
|
||||
.accessibilityIdentifier("personalReplyStyle.newSinceLast")
|
||||
}
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
@@ -328,24 +384,31 @@ struct PersonalReplyStyleSection: View {
|
||||
from corpus: PolishStyleLearningCorpus,
|
||||
replacing existingPack: PolishStylePack? = nil
|
||||
) {
|
||||
guard isEligible(corpus), !isGenerating, generationTask == nil else { return }
|
||||
let prior = existingPack.flatMap(learningState.prior(for:))
|
||||
guard isEligible(corpus, prior: prior),
|
||||
!isGenerating,
|
||||
generationTask == nil else { return }
|
||||
let id = UUID()
|
||||
generationID = id
|
||||
isGenerating = true
|
||||
generationTask = Task { @MainActor in
|
||||
defer { finishGeneration(id: id) }
|
||||
do {
|
||||
let generated = try await generator(
|
||||
let result = try await generator(
|
||||
corpus,
|
||||
ClipboardReplyFeedbackStore.shared.learningExamples(),
|
||||
config.uiLanguage
|
||||
config.uiLanguage,
|
||||
prior
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
guard generationID == id, editingPack == nil else { return }
|
||||
let generated = result.pack
|
||||
// Always let the user inspect and edit the learned prompt before
|
||||
// it is saved, synced, or enabled.
|
||||
// it is saved, synced, or enabled. Saving overwrites the existing
|
||||
// style, including any manual edits to it.
|
||||
let reviewPack: PolishStylePack
|
||||
if let existingPack {
|
||||
editingPack = PolishStylePack(
|
||||
reviewPack = PolishStylePack(
|
||||
id: existingPack.id,
|
||||
name: generated.name,
|
||||
prompt: generated.prompt,
|
||||
@@ -356,8 +419,12 @@ struct PersonalReplyStyleSection: View {
|
||||
updatedAt: Date()
|
||||
)
|
||||
} else {
|
||||
editingPack = generated
|
||||
reviewPack = generated
|
||||
}
|
||||
pendingLearning = result.prior.map {
|
||||
PendingLearning(packID: reviewPack.id, prior: $0)
|
||||
}
|
||||
editingPack = reviewPack
|
||||
} catch {
|
||||
guard generationID == id,
|
||||
!Task.isCancelled,
|
||||
@@ -395,6 +462,10 @@ struct PersonalReplyStyleSection: View {
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
store.setPersonalReplyStyleId(pack.id)
|
||||
enabledStyleID = store.personalReplyStyleId
|
||||
if let pendingLearning, pendingLearning.packID == pack.id {
|
||||
learningState.save(pendingLearning.prior, for: pack)
|
||||
self.pendingLearning = nil
|
||||
}
|
||||
pushSync()
|
||||
return true
|
||||
} catch {
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
/// The prior is nil for a first or full relearn; with one, the generator
|
||||
/// learns incrementally from examples after `learnedThrough`.
|
||||
typealias LearnedStyleGenerator = @MainActor @Sendable (
|
||||
PolishStyleLearningCorpus,
|
||||
[PolishStyleReplyLearningExample],
|
||||
AppUILanguage
|
||||
) async throws -> PolishStylePack
|
||||
AppUILanguage,
|
||||
PolishStyleLearningPriorEvidence?
|
||||
) async throws -> PolishStyleLearningResult
|
||||
|
||||
private struct PolishStyleErrorAlert {
|
||||
let title: String
|
||||
|
||||
@@ -81,10 +81,13 @@ struct PolishStylesScreenshotHarness: View {
|
||||
)
|
||||
} else if simulatesGeneration {
|
||||
PersonalReplyStyleHost(
|
||||
generator: { _, _, language in
|
||||
generator: { _, _, language, _ in
|
||||
try await Task.sleep(for: .seconds(1.8))
|
||||
return ReleaseNotesScreenshotFixture.generatedStyle(
|
||||
language: language
|
||||
return PolishStyleLearningResult(
|
||||
pack: ReleaseNotesScreenshotFixture.generatedStyle(
|
||||
language: language
|
||||
),
|
||||
prior: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -115,7 +118,7 @@ private struct PolishStylesServiceUITestHarness: View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if showsStyles {
|
||||
PersonalReplyStyleHost(
|
||||
generator: { corpus, replyExamples, language in
|
||||
generator: { corpus, replyExamples, language, prior in
|
||||
let client = PolishStylesUITestScriptedLLMClient(
|
||||
responses: ReleaseNotesScreenshotFixture.serviceResponses(
|
||||
language: language,
|
||||
@@ -129,7 +132,7 @@ private struct PolishStylesServiceUITestHarness: View {
|
||||
store: AppGroupStore(),
|
||||
client: client
|
||||
)
|
||||
.generateStyle(
|
||||
.learnStyle(
|
||||
from: corpus,
|
||||
replyExamples: replyExamples,
|
||||
outputLanguage: language,
|
||||
@@ -138,7 +141,8 @@ private struct PolishStylesServiceUITestHarness: View {
|
||||
? PolishStyleLearningCorpusBuilder
|
||||
.testBuildEffectiveCharacterCount
|
||||
: PolishStyleLearningCorpusBuilder
|
||||
.requiredEffectiveCharacterCount
|
||||
.requiredEffectiveCharacterCount,
|
||||
prior: prior
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -264,6 +268,7 @@ private final class PolishStylesUITestScriptedLLMClient: LLMClient, @unchecked S
|
||||
@MainActor
|
||||
struct HomeDictionaryScreenshotHarness: View {
|
||||
@StateObject private var flowManager = FlowSessionManager()
|
||||
@StateObject private var accountSession = AccountSessionCoordinator(dependencies: .unavailable)
|
||||
private let language: AppUILanguage
|
||||
|
||||
init() {
|
||||
@@ -277,6 +282,7 @@ struct HomeDictionaryScreenshotHarness: View {
|
||||
ThemedRoot {
|
||||
HomeView()
|
||||
.environmentObject(flowManager)
|
||||
.environmentObject(accountSession)
|
||||
}
|
||||
.environment(\.locale, language.swiftUILocale)
|
||||
.preferredColorScheme(.light)
|
||||
@@ -456,6 +462,7 @@ private enum ReleaseNotesScreenshotFixture {
|
||||
store.setPolishStyleCatalog(PolishStyleCatalog())
|
||||
store.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
|
||||
store.setPersonalReplyStyleId("")
|
||||
PersonalStyleLearningStateStore.shared.clear()
|
||||
}
|
||||
|
||||
/// Seeds varied per-day dictation totals across the current month so the
|
||||
|
||||
@@ -91,6 +91,7 @@ struct SettingsVersionRow: View {
|
||||
|
||||
// MARK: - Config entry summaries (shown on Settings home)
|
||||
|
||||
@MainActor
|
||||
enum SettingsConfigSummary {
|
||||
static func speechRecognition(config: ProviderConfig) -> String {
|
||||
if config.engineMode == "local" {
|
||||
|
||||
@@ -14,7 +14,7 @@ import SwiftUI
|
||||
|
||||
struct SmartReplyDemoView: View {
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
@StateObject private var state = KeyboardState()
|
||||
@State private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
"NSContactsUsageDescription" = "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.";
|
||||
"NSRemindersFullAccessUsageDescription" = "OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.";
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription" = "OSGKeyboard adds calendar events only when you choose Extract Events for copied text. It never reads your existing events.";
|
||||
"NSPhotoLibraryAddUsageDescription" = "OSGKeyboard saves your invitation poster to Photos only when you choose Save Image.";
|
||||
|
||||
@@ -527,14 +527,46 @@
|
||||
"home.setup.permission.mic" = "Microphone access is off — voice input won't work.";
|
||||
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
|
||||
"home.setup.permission.both" = "Microphone and speech recognition are off — voice input won't work.";
|
||||
"home.setup.permission.request" = "Next";
|
||||
"home.setup.cloudIncomplete" = "Cloud recognition and AI polish need API keys.";
|
||||
"home.setup.polishKeyMissing" = "Add an API key to enable AI polish. Without it, local dictation still inserts raw recognized text.";
|
||||
"home.setup.byok.configure" = "Configure API Key";
|
||||
"home.setup.credits.alternative" = "Or use OSG credits for transcription and AI without managing API keys.";
|
||||
"home.setup.credits.open" = "Use Credits";
|
||||
"home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings.";
|
||||
"home.setup.keyboardHint.dismiss" = "Got it";
|
||||
"home.flow.source.credits" = "OSG Credits";
|
||||
"home.flow.source.ownKey" = "Own API key";
|
||||
"home.flow.source.dictationOnly" = "Dictation only";
|
||||
|
||||
/* Home · Account surface (one card at most) */
|
||||
"home.account.status.setupRequired" = "Setup required";
|
||||
"home.account.status.dictationReady" = "Dictation available";
|
||||
"home.account.status.invitation" = "Invitation detected";
|
||||
"home.account.status.signedIn" = "Account connected";
|
||||
"home.account.status.paused" = "Cloud service paused";
|
||||
"home.account.status.connection" = "Connection issue";
|
||||
"home.account.permissions.title" = "Complete basic setup";
|
||||
"home.account.permissions.continue" = "Continue";
|
||||
"home.account.permissions.footnote" = "You can change these later in Settings";
|
||||
"home.account.setup.title.polish" = "Enable AI polish";
|
||||
"home.account.setup.title.cloud" = "Start using voice input";
|
||||
"home.account.setup.body.polish" = "Dictation already works. Sign in to use OSG Credits without managing API keys.";
|
||||
"home.account.setup.body.cloud" = "Cloud recognition needs an API key. Sign in to use OSG Credits instead, with nothing to configure.";
|
||||
"home.account.setup.footnote" = "You can keep dictating without setting this up";
|
||||
"home.account.ownKey" = "Use my own API key";
|
||||
"home.account.haveInvitation" = "Have an invitation?";
|
||||
"home.account.invitation.title" = "Invitation saved";
|
||||
"home.account.invitation.body" = "Sign in to accept your friend's invitation and earn Credits together.";
|
||||
"home.account.invitation.code" = "Code: %@";
|
||||
"home.account.invitation.change" = "Change";
|
||||
"home.account.invitation.later" = "Not now";
|
||||
"home.account.choose.title" = "Choose your AI service";
|
||||
"home.account.choose.body" = "Your account has %@ Credits. You can also keep using your own API key.";
|
||||
"home.account.choose.bodyLoading" = "Use OSG Credits for recognition and polish, or keep using your own API key.";
|
||||
"home.account.useCredits" = "Use OSG Credits";
|
||||
"home.account.depleted.title" = "You're out of Credits";
|
||||
"home.account.depleted.body" = "Add Credits or switch to your own API key to keep using cloud services.";
|
||||
"home.account.depleted.add" = "Add Credits";
|
||||
"home.account.unavailable.title" = "Can't reach your OSG account";
|
||||
"home.account.unavailable.body" = "Check your connection and try again, or switch to your own API key for now.";
|
||||
"home.account.promo.title" = "Done managing API keys?";
|
||||
"home.account.promo.body" = "Sign in to use OSG Credits";
|
||||
"home.account.usingOwnKey" = "Currently using your own API key";
|
||||
"home.flow.starting" = "Starting voice session…";
|
||||
"home.flow.preparingProgress" = "Preparing background session (%d/%d)…";
|
||||
"home.flow.recoveringProgress" = "Restoring background session (%d/%d)…";
|
||||
@@ -560,7 +592,9 @@
|
||||
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.wide.mode.cloud" = "Cloud";
|
||||
"home.wide.mode.local" = "On-device";
|
||||
"home.wide.devices" = "iPhone & iPad";
|
||||
"home.wide.status.engine" = "Engine";
|
||||
"home.wide.status.translation" = "Translation";
|
||||
"home.wide.status.setUp" = "Finish AI service setup";
|
||||
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+";
|
||||
"home.engine.warming" = "Loading ASR model into memory…";
|
||||
"home.engine.downloading" = "Downloading ASR model…";
|
||||
@@ -621,6 +655,10 @@
|
||||
"skills.extractConclusions.description" = "Pull out decisions, conclusions, and next steps from long text.";
|
||||
"skills.translate.name" = "Translate";
|
||||
"skills.translate.description" = "Translate copied text in another language into the device's current primary system language.";
|
||||
"skills.translateToTarget.name" = "Translate to Target Language";
|
||||
"skills.translateToTarget.description" = "Translate copied text in your device language into the translation target language set in Settings. Hidden while translation is off.";
|
||||
"skills.polishOriginal.name" = "Polish";
|
||||
"skills.polishOriginal.description" = "For copied text in another language: fix grammar, improve wording, and polish it while keeping the original language.";
|
||||
"skills.acceptInvitation.name" = "Accept invitation";
|
||||
"skills.acceptInvitation.description" = "Draft a concise acceptance and confirm the important details.";
|
||||
"skills.declineInvitation.name" = "Decline politely";
|
||||
@@ -715,6 +753,7 @@
|
||||
"personalReplyStyle.generatedAt" = "Generated";
|
||||
"personalReplyStyle.evidenceSummary" = "%lld ASR characters · %lld reply choices · %lld final edits";
|
||||
"personalReplyStyle.confidence" = "Confidence: %lld%%";
|
||||
"personalReplyStyle.newSinceLast" = "New since last generation: %lld / %lld characters";
|
||||
"personalReplyStyle.regenerate" = "Regenerate";
|
||||
"personalReplyStyle.regenerating" = "Regenerating…";
|
||||
"personalReplyStyle.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
|
||||
@@ -828,6 +867,9 @@
|
||||
"hostApp.wecom" = "WeCom";
|
||||
"hostApp.dingtalk" = "DingTalk";
|
||||
"hostApp.lark" = "Lark";
|
||||
"hostApp.tencentMeeting" = "Tencent Meeting";
|
||||
"hostApp.qqmail" = "QQ Mail";
|
||||
"hostApp.wps" = "WPS Office";
|
||||
"hostApp.telegram" = "Telegram";
|
||||
"hostApp.whatsapp" = "WhatsApp";
|
||||
"hostApp.line" = "LINE";
|
||||
@@ -835,28 +877,56 @@
|
||||
"hostApp.slack" = "Slack";
|
||||
"hostApp.teams" = "Microsoft Teams";
|
||||
"hostApp.discord" = "Discord";
|
||||
"hostApp.notion" = "Notion";
|
||||
"hostApp.bear" = "Bear";
|
||||
"hostApp.obsidian" = "Obsidian";
|
||||
"hostApp.drafts" = "Drafts";
|
||||
"hostApp.gmail" = "Gmail";
|
||||
"hostApp.outlook" = "Outlook";
|
||||
"hostApp.chrome" = "Chrome";
|
||||
"hostApp.weibo" = "Weibo";
|
||||
"hostApp.xiaohongshu" = "RED";
|
||||
"hostApp.signal" = "Signal";
|
||||
"hostApp.kakaotalk" = "KakaoTalk";
|
||||
"hostApp.viber" = "Viber";
|
||||
"hostApp.zalo" = "Zalo";
|
||||
"hostApp.skype" = "Skype";
|
||||
"hostApp.zoom" = "Zoom";
|
||||
"hostApp.tinder" = "Tinder";
|
||||
"hostApp.notion" = "Notion";
|
||||
"hostApp.bear" = "Bear";
|
||||
"hostApp.obsidian" = "Obsidian";
|
||||
"hostApp.drafts" = "Drafts";
|
||||
"hostApp.things" = "Things";
|
||||
"hostApp.todoist" = "Todoist";
|
||||
"hostApp.evernote" = "Evernote";
|
||||
"hostApp.onenote" = "OneNote";
|
||||
"hostApp.word" = "Microsoft Word";
|
||||
"hostApp.excel" = "Microsoft Excel";
|
||||
"hostApp.gmail" = "Gmail";
|
||||
"hostApp.outlook" = "Outlook";
|
||||
"hostApp.spark" = "Spark";
|
||||
"hostApp.yahooMail" = "Yahoo Mail";
|
||||
"hostApp.googleDocs" = "Google Docs";
|
||||
"hostApp.googleSheets" = "Google Sheets";
|
||||
"hostApp.googleDrive" = "Google Drive";
|
||||
"hostApp.googleTranslate" = "Google Translate";
|
||||
"hostApp.google" = "Google";
|
||||
"hostApp.chrome" = "Chrome";
|
||||
"hostApp.firefox" = "Firefox";
|
||||
"hostApp.edge" = "Edge";
|
||||
"hostApp.chatgpt" = "ChatGPT";
|
||||
"hostApp.claude" = "Claude";
|
||||
"hostApp.weibo" = "Weibo";
|
||||
"hostApp.xiaohongshu" = "RED";
|
||||
"hostApp.zhihu" = "Zhihu";
|
||||
"hostApp.bilibili" = "Bilibili";
|
||||
"hostApp.douyin" = "Douyin";
|
||||
"hostApp.douyinLite" = "Douyin Lite";
|
||||
"hostApp.kuaishou" = "Kuaishou";
|
||||
"hostApp.kuaishouLite" = "Kuaishou Lite";
|
||||
"hostApp.toutiao" = "Toutiao";
|
||||
"hostApp.douban" = "Douban";
|
||||
"hostApp.baidu" = "Baidu";
|
||||
"hostApp.youku" = "Youku";
|
||||
"hostApp.tencentVideo" = "Tencent Video";
|
||||
"hostApp.iqiyi" = "iQIYI";
|
||||
"hostApp.mangoTV" = "Mango TV";
|
||||
"hostApp.qqMusic" = "QQ Music";
|
||||
"hostApp.neteaseMusic" = "NetEase Cloud Music";
|
||||
"hostApp.kugou" = "Kugou Music";
|
||||
"hostApp.ximalaya" = "Ximalaya";
|
||||
"hostApp.weread" = "WeRead";
|
||||
"hostApp.facebook" = "Facebook";
|
||||
"hostApp.instagram" = "Instagram";
|
||||
"hostApp.x" = "X";
|
||||
@@ -864,10 +934,61 @@
|
||||
"hostApp.snapchat" = "Snapchat";
|
||||
"hostApp.reddit" = "Reddit";
|
||||
"hostApp.pinterest" = "Pinterest";
|
||||
"hostApp.zhihu" = "Zhihu";
|
||||
"hostApp.bilibili" = "Bilibili";
|
||||
"hostApp.douyin" = "Douyin";
|
||||
"hostApp.tiktok" = "TikTok";
|
||||
"hostApp.youtube" = "YouTube";
|
||||
"hostApp.linkedin" = "LinkedIn";
|
||||
"hostApp.tumblr" = "Tumblr";
|
||||
"hostApp.twitch" = "Twitch";
|
||||
"hostApp.spotify" = "Spotify";
|
||||
"hostApp.netflix" = "Netflix";
|
||||
"hostApp.capcut" = "CapCut";
|
||||
"hostApp.duolingo" = "Duolingo";
|
||||
"hostApp.taobao" = "Taobao";
|
||||
"hostApp.tmall" = "Tmall";
|
||||
"hostApp.jd" = "JD.com";
|
||||
"hostApp.pinduoduo" = "Pinduoduo";
|
||||
"hostApp.xianyu" = "Xianyu";
|
||||
"hostApp.alibaba1688" = "1688";
|
||||
"hostApp.vipshop" = "Vipshop";
|
||||
"hostApp.meituan" = "Meituan";
|
||||
"hostApp.meituanWaimai" = "Meituan Waimai";
|
||||
"hostApp.eleme" = "Taobao Shangou";
|
||||
"hostApp.dianping" = "Dianping";
|
||||
"hostApp.wuba" = "58.com";
|
||||
"hostApp.didi" = "DiDi";
|
||||
"hostApp.amap" = "Amap";
|
||||
"hostApp.baiduMap" = "Baidu Maps";
|
||||
"hostApp.tencentMap" = "Tencent Maps";
|
||||
"hostApp.ctrip" = "Ctrip";
|
||||
"hostApp.baiduNetdisk" = "Baidu Netdisk";
|
||||
"hostApp.ucBrowser" = "UC Browser";
|
||||
"hostApp.qqBrowser" = "QQ Browser";
|
||||
"hostApp.alipay" = "Alipay";
|
||||
"hostApp.unionpay" = "UnionPay";
|
||||
"hostApp.cmb" = "China Merchants Bank";
|
||||
"hostApp.ccb" = "China Construction Bank";
|
||||
"hostApp.abc" = "Agricultural Bank of China";
|
||||
"hostApp.amazon" = "Amazon";
|
||||
"hostApp.walmart" = "Walmart";
|
||||
"hostApp.ebay" = "eBay";
|
||||
"hostApp.aliexpress" = "AliExpress";
|
||||
"hostApp.uber" = "Uber";
|
||||
"hostApp.lyft" = "Lyft";
|
||||
"hostApp.doordash" = "DoorDash";
|
||||
"hostApp.airbnb" = "Airbnb";
|
||||
"hostApp.googleMaps" = "Google Maps";
|
||||
"hostApp.paypal" = "PayPal";
|
||||
"hostApp.venmo" = "Venmo";
|
||||
"hostApp.cashApp" = "Cash App";
|
||||
"hostApp.messages" = "Messages";
|
||||
"hostApp.notes" = "Notes";
|
||||
"hostApp.mail" = "Mail";
|
||||
"hostApp.reminders" = "Reminders";
|
||||
"hostApp.calendar" = "Calendar";
|
||||
"hostApp.shortcuts" = "Shortcuts";
|
||||
"hostApp.files" = "Files";
|
||||
"hostApp.maps" = "Maps";
|
||||
"hostApp.appStore" = "App Store";
|
||||
"flow.warning.polishDegradedQuality" = "Polish output failed validation; inserted a conservative version.";
|
||||
"flow.error.editLastInputFailed" = "Could not complete the edit. Please try again.";
|
||||
"flow.error.aiQuestionFailed" = "AI response failed. Please try again.";
|
||||
@@ -982,6 +1103,35 @@
|
||||
"account.referral.pendingAfterSignIn" = "Your invitation is saved and will be redeemed after sign-in.";
|
||||
"account.referral.rewardGranted" = "You received %@ credits from the invitation.";
|
||||
"account.referral.rewardGrantedGeneric" = "Your invitation reward has been added to your balance.";
|
||||
"account.referral.poster.title" = "Invite Friends";
|
||||
"account.referral.poster.accessibility" = "Invitation poster with a QR code for your invitation link";
|
||||
"account.referral.poster.enlargeHint" = "Shows the QR code full screen for scanning in person.";
|
||||
"account.referral.poster.save" = "Save Image";
|
||||
"account.referral.poster.share" = "Share";
|
||||
"account.referral.poster.copyLink" = "Copy Link";
|
||||
"account.referral.poster.footnote" = "Send the image to a friend, or tap the poster to show a large code to scan in person. Once your friend signs up and completes a first qualifying use, you both earn credits.";
|
||||
"account.referral.poster.saved" = "Saved to Photos";
|
||||
"account.referral.poster.saveDenied" = "Allow OSGKeyboard to add photos in Settings, then try again.";
|
||||
"account.referral.poster.saveFailed" = "Couldn’t save the image. Please try again.";
|
||||
"account.referral.poster.linkCopied" = "Invitation link copied";
|
||||
"account.referral.codeEntry.title" = "Have an invitation?";
|
||||
"account.referral.codeEntry.body" = "Enter your friend’s code, then sign in to accept it.";
|
||||
"account.referral.codeEntry.bodySignedIn" = "Enter your friend’s code and you both earn credits.";
|
||||
"account.referral.codeEntry.button" = "I have a code";
|
||||
"account.referral.codeEntry.windowDescription" = "Enter a code, or invite more friends for credits";
|
||||
"account.referral.codeEntry.sheetTitle" = "Enter Invitation Code";
|
||||
"account.referral.codeEntry.sheetBody" = "Enter the 8-character code from your friend’s poster or invitation page, or paste the invitation link.";
|
||||
"account.referral.codeEntry.placeholder" = "e.g. K7M2-Q9XA";
|
||||
"account.referral.codeEntry.fieldLabel" = "Invitation code";
|
||||
"account.referral.codeEntry.confirm" = "Confirm";
|
||||
"account.referral.codeEntry.invalid" = "That doesn’t look like an invitation code. Check it and try again.";
|
||||
"account.referral.codeEntry.redeemed" = "Invitation accepted. You both earn credits after qualifying usage.";
|
||||
"account.referral.codeEntry.inviteMore" = "You can also invite more friends for credits";
|
||||
"account.referral.redeem.error.invalid" = "This invitation code is invalid, or the campaign has ended.";
|
||||
"account.referral.redeem.error.expired" = "The time to add an invitation to this account has passed.";
|
||||
"account.referral.redeem.error.self" = "You can’t use your own invitation.";
|
||||
"account.referral.redeem.error.alreadyBound" = "This account has already accepted another invitation.";
|
||||
"account.referral.redeem.error.ineligible" = "This account can’t accept this invitation.";
|
||||
"account.security.section" = "Account controls";
|
||||
"account.signOut.action" = "Sign Out";
|
||||
"account.signOut.confirmTitle" = "Sign out?";
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
"NSContactsUsageDescription" = "仅当你为复制的电话号码选择“新建联系人”时,OSGKeyboard 才会使用通讯录。";
|
||||
"NSRemindersFullAccessUsageDescription" = "仅当你为复制的文本选择“提取待办”时,OSGKeyboard 才会添加提醒事项。";
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription" = "仅当你为复制的文本选择“提取日程”时,OSGKeyboard 才会添加日历事件,不会读取你已有的日程。";
|
||||
"NSPhotoLibraryAddUsageDescription" = "仅当你选择“保存图片”时,OSGKeyboard 才会把邀请海报存入相册。";
|
||||
|
||||
@@ -527,14 +527,46 @@
|
||||
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
|
||||
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
|
||||
"home.setup.permission.both" = "麦克风和语音识别还没授权,语音输入用不了。";
|
||||
"home.setup.permission.request" = "下一步";
|
||||
"home.setup.cloudIncomplete" = "云端识别和 AI 润色需要配置 API Key。";
|
||||
"home.setup.polishKeyMissing" = "配置 API Key 后可启用 AI 润色;未配置时,本地听写仍会插入原始识别文本。";
|
||||
"home.setup.byok.configure" = "配置 API Key";
|
||||
"home.setup.credits.alternative" = "也可以使用 OSG 积分完成语音转写和 AI,无需管理 API Key。";
|
||||
"home.setup.credits.open" = "使用积分";
|
||||
"home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。";
|
||||
"home.setup.keyboardHint.dismiss" = "知道了";
|
||||
"home.flow.source.credits" = "OSG 积分";
|
||||
"home.flow.source.ownKey" = "自己的 API Key";
|
||||
"home.flow.source.dictationOnly" = "仅听写";
|
||||
|
||||
/* Home · Account surface (one card at most) */
|
||||
"home.account.status.setupRequired" = "需要完成设置";
|
||||
"home.account.status.dictationReady" = "听写可用";
|
||||
"home.account.status.invitation" = "检测到邀请";
|
||||
"home.account.status.signedIn" = "账户已连接";
|
||||
"home.account.status.paused" = "云端服务已暂停";
|
||||
"home.account.status.connection" = "连接异常";
|
||||
"home.account.permissions.title" = "完成基础设置";
|
||||
"home.account.permissions.continue" = "继续";
|
||||
"home.account.permissions.footnote" = "稍后也可以前往系统设置修改";
|
||||
"home.account.setup.title.polish" = "启用 AI 润色";
|
||||
"home.account.setup.title.cloud" = "开始使用语音输入";
|
||||
"home.account.setup.body.polish" = "听写已经可以使用。登录后可使用 OSG 积分,无需配置 API Key。";
|
||||
"home.account.setup.body.cloud" = "云端识别需要 API Key。登录后可直接使用 OSG 积分,无需自己配置。";
|
||||
"home.account.setup.footnote" = "不设置也可以继续使用听写";
|
||||
"home.account.ownKey" = "使用自己的 API Key";
|
||||
"home.account.haveInvitation" = "有邀请码?";
|
||||
"home.account.invitation.title" = "邀请已保存";
|
||||
"home.account.invitation.body" = "登录后即可接受好友邀请,双方都可以获得积分。";
|
||||
"home.account.invitation.code" = "邀请码:%@";
|
||||
"home.account.invitation.change" = "修改";
|
||||
"home.account.invitation.later" = "暂不登录";
|
||||
"home.account.choose.title" = "选择 AI 服务";
|
||||
"home.account.choose.body" = "账户中有 %@ 积分。你也可以继续使用自己的 API Key。";
|
||||
"home.account.choose.bodyLoading" = "使用 OSG 积分完成识别和润色,或继续使用自己的 API Key。";
|
||||
"home.account.useCredits" = "使用 OSG 积分";
|
||||
"home.account.depleted.title" = "积分已用完";
|
||||
"home.account.depleted.body" = "添加积分,或切换到自己的 API Key 以继续使用云端服务。";
|
||||
"home.account.depleted.add" = "添加积分";
|
||||
"home.account.unavailable.title" = "无法连接 OSG 账户";
|
||||
"home.account.unavailable.body" = "请检查网络后重试,或临时切换到自己的 API Key。";
|
||||
"home.account.promo.title" = "不想管理 API Key?";
|
||||
"home.account.promo.body" = "登录后可使用 OSG 积分";
|
||||
"home.account.usingOwnKey" = "当前使用自己的 API Key";
|
||||
"home.flow.starting" = "正在启动语音会话…";
|
||||
"home.flow.preparingProgress" = "正在准备后台会话(%d/%d)…";
|
||||
"home.flow.recoveringProgress" = "正在恢复后台会话(%d/%d)…";
|
||||
@@ -560,7 +592,9 @@
|
||||
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
|
||||
"home.wide.mode.cloud" = "云端";
|
||||
"home.wide.mode.local" = "本机";
|
||||
"home.wide.devices" = "iPhone 与 iPad";
|
||||
"home.wide.status.engine" = "引擎";
|
||||
"home.wide.status.translation" = "翻译";
|
||||
"home.wide.status.setUp" = "完成 AI 服务设置";
|
||||
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本";
|
||||
"home.engine.warming" = "正在加载语音识别模型…";
|
||||
"home.engine.downloading" = "正在下载语音识别模型…";
|
||||
@@ -621,6 +655,10 @@
|
||||
"skills.extractConclusions.description" = "从长文提取决定、结论和下一步。";
|
||||
"skills.translate.name" = "翻译";
|
||||
"skills.translate.description" = "将不同语言的复制文本翻译为设备当前的首选系统语言。";
|
||||
"skills.translateToTarget.name" = "翻译为目标语言";
|
||||
"skills.translateToTarget.description" = "将设备语言的复制文本翻译为「设置」中的翻译目标语言;翻译关闭时不显示。";
|
||||
"skills.polishOriginal.name" = "优化";
|
||||
"skills.polishOriginal.description" = "针对其他语言的复制文本:纠正语法、优化措辞并润色,结果保持原语言。";
|
||||
"skills.acceptInvitation.name" = "接受邀约";
|
||||
"skills.acceptInvitation.description" = "起草简洁的接受回复,并确认重要信息。";
|
||||
"skills.declineInvitation.name" = "委婉拒绝";
|
||||
@@ -715,6 +753,7 @@
|
||||
"personalReplyStyle.generatedAt" = "生成于";
|
||||
"personalReplyStyle.evidenceSummary" = "%lld 个 ASR 字符 · %lld 次回复选择 · %lld 次最终改稿";
|
||||
"personalReplyStyle.confidence" = "置信度:%lld%%";
|
||||
"personalReplyStyle.newSinceLast" = "自上次生成后新增 %lld / %lld 字";
|
||||
"personalReplyStyle.regenerate" = "重新生成";
|
||||
"personalReplyStyle.regenerating" = "重新生成中…";
|
||||
"personalReplyStyle.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
@@ -828,6 +867,9 @@
|
||||
"hostApp.wecom" = "企业微信";
|
||||
"hostApp.dingtalk" = "钉钉";
|
||||
"hostApp.lark" = "飞书";
|
||||
"hostApp.tencentMeeting" = "腾讯会议";
|
||||
"hostApp.qqmail" = "QQ邮箱";
|
||||
"hostApp.wps" = "WPS Office";
|
||||
"hostApp.telegram" = "Telegram";
|
||||
"hostApp.whatsapp" = "WhatsApp";
|
||||
"hostApp.line" = "LINE";
|
||||
@@ -835,28 +877,56 @@
|
||||
"hostApp.slack" = "Slack";
|
||||
"hostApp.teams" = "Microsoft Teams";
|
||||
"hostApp.discord" = "Discord";
|
||||
"hostApp.notion" = "Notion";
|
||||
"hostApp.bear" = "Bear";
|
||||
"hostApp.obsidian" = "Obsidian";
|
||||
"hostApp.drafts" = "Drafts";
|
||||
"hostApp.gmail" = "Gmail";
|
||||
"hostApp.outlook" = "Outlook";
|
||||
"hostApp.chrome" = "Chrome";
|
||||
"hostApp.weibo" = "微博";
|
||||
"hostApp.xiaohongshu" = "小红书";
|
||||
"hostApp.signal" = "Signal";
|
||||
"hostApp.kakaotalk" = "KakaoTalk";
|
||||
"hostApp.viber" = "Viber";
|
||||
"hostApp.zalo" = "Zalo";
|
||||
"hostApp.skype" = "Skype";
|
||||
"hostApp.zoom" = "Zoom";
|
||||
"hostApp.tinder" = "Tinder";
|
||||
"hostApp.notion" = "Notion";
|
||||
"hostApp.bear" = "熊掌记";
|
||||
"hostApp.obsidian" = "Obsidian";
|
||||
"hostApp.drafts" = "Drafts";
|
||||
"hostApp.things" = "Things";
|
||||
"hostApp.todoist" = "Todoist";
|
||||
"hostApp.evernote" = "印象笔记";
|
||||
"hostApp.evernote" = "Evernote";
|
||||
"hostApp.onenote" = "OneNote";
|
||||
"hostApp.word" = "Word";
|
||||
"hostApp.excel" = "Excel";
|
||||
"hostApp.gmail" = "Gmail";
|
||||
"hostApp.outlook" = "Outlook";
|
||||
"hostApp.spark" = "Spark";
|
||||
"hostApp.yahooMail" = "雅虎邮箱";
|
||||
"hostApp.googleDocs" = "Google 文档";
|
||||
"hostApp.googleSheets" = "Google 表格";
|
||||
"hostApp.googleDrive" = "Google 云端硬盘";
|
||||
"hostApp.googleTranslate" = "Google 翻译";
|
||||
"hostApp.google" = "Google";
|
||||
"hostApp.chrome" = "Chrome";
|
||||
"hostApp.firefox" = "Firefox";
|
||||
"hostApp.edge" = "Edge";
|
||||
"hostApp.chatgpt" = "ChatGPT";
|
||||
"hostApp.claude" = "Claude";
|
||||
"hostApp.weibo" = "微博";
|
||||
"hostApp.xiaohongshu" = "小红书";
|
||||
"hostApp.zhihu" = "知乎";
|
||||
"hostApp.bilibili" = "哔哩哔哩";
|
||||
"hostApp.douyin" = "抖音";
|
||||
"hostApp.douyinLite" = "抖音极速版";
|
||||
"hostApp.kuaishou" = "快手";
|
||||
"hostApp.kuaishouLite" = "快手极速版";
|
||||
"hostApp.toutiao" = "今日头条";
|
||||
"hostApp.douban" = "豆瓣";
|
||||
"hostApp.baidu" = "百度";
|
||||
"hostApp.youku" = "优酷视频";
|
||||
"hostApp.tencentVideo" = "腾讯视频";
|
||||
"hostApp.iqiyi" = "爱奇艺";
|
||||
"hostApp.mangoTV" = "芒果TV";
|
||||
"hostApp.qqMusic" = "QQ音乐";
|
||||
"hostApp.neteaseMusic" = "网易云音乐";
|
||||
"hostApp.kugou" = "酷狗音乐";
|
||||
"hostApp.ximalaya" = "喜马拉雅";
|
||||
"hostApp.weread" = "微信读书";
|
||||
"hostApp.facebook" = "Facebook";
|
||||
"hostApp.instagram" = "Instagram";
|
||||
"hostApp.x" = "X";
|
||||
@@ -864,10 +934,61 @@
|
||||
"hostApp.snapchat" = "Snapchat";
|
||||
"hostApp.reddit" = "Reddit";
|
||||
"hostApp.pinterest" = "Pinterest";
|
||||
"hostApp.zhihu" = "知乎";
|
||||
"hostApp.bilibili" = "哔哩哔哩";
|
||||
"hostApp.douyin" = "抖音";
|
||||
"hostApp.tiktok" = "TikTok";
|
||||
"hostApp.youtube" = "YouTube";
|
||||
"hostApp.linkedin" = "领英";
|
||||
"hostApp.tumblr" = "Tumblr";
|
||||
"hostApp.twitch" = "Twitch";
|
||||
"hostApp.spotify" = "Spotify";
|
||||
"hostApp.netflix" = "Netflix";
|
||||
"hostApp.capcut" = "CapCut";
|
||||
"hostApp.duolingo" = "多邻国";
|
||||
"hostApp.taobao" = "淘宝";
|
||||
"hostApp.tmall" = "天猫";
|
||||
"hostApp.jd" = "京东";
|
||||
"hostApp.pinduoduo" = "拼多多";
|
||||
"hostApp.xianyu" = "闲鱼";
|
||||
"hostApp.alibaba1688" = "阿里巴巴 1688";
|
||||
"hostApp.vipshop" = "唯品会";
|
||||
"hostApp.meituan" = "美团";
|
||||
"hostApp.meituanWaimai" = "美团外卖";
|
||||
"hostApp.eleme" = "淘宝闪购";
|
||||
"hostApp.dianping" = "大众点评";
|
||||
"hostApp.wuba" = "58同城";
|
||||
"hostApp.didi" = "滴滴出行";
|
||||
"hostApp.amap" = "高德地图";
|
||||
"hostApp.baiduMap" = "百度地图";
|
||||
"hostApp.tencentMap" = "腾讯地图";
|
||||
"hostApp.ctrip" = "携程旅行";
|
||||
"hostApp.baiduNetdisk" = "百度网盘";
|
||||
"hostApp.ucBrowser" = "UC浏览器";
|
||||
"hostApp.qqBrowser" = "QQ浏览器";
|
||||
"hostApp.alipay" = "支付宝";
|
||||
"hostApp.unionpay" = "云闪付";
|
||||
"hostApp.cmb" = "招商银行";
|
||||
"hostApp.ccb" = "中国建设银行";
|
||||
"hostApp.abc" = "中国农业银行";
|
||||
"hostApp.amazon" = "亚马逊";
|
||||
"hostApp.walmart" = "沃尔玛";
|
||||
"hostApp.ebay" = "eBay";
|
||||
"hostApp.aliexpress" = "速卖通";
|
||||
"hostApp.uber" = "Uber";
|
||||
"hostApp.lyft" = "Lyft";
|
||||
"hostApp.doordash" = "DoorDash";
|
||||
"hostApp.airbnb" = "爱彼迎";
|
||||
"hostApp.googleMaps" = "Google 地图";
|
||||
"hostApp.paypal" = "PayPal";
|
||||
"hostApp.venmo" = "Venmo";
|
||||
"hostApp.cashApp" = "Cash App";
|
||||
"hostApp.messages" = "信息";
|
||||
"hostApp.notes" = "备忘录";
|
||||
"hostApp.mail" = "邮件";
|
||||
"hostApp.reminders" = "提醒事项";
|
||||
"hostApp.calendar" = "日历";
|
||||
"hostApp.shortcuts" = "快捷指令";
|
||||
"hostApp.files" = "文件";
|
||||
"hostApp.maps" = "地图";
|
||||
"hostApp.appStore" = "App Store";
|
||||
"flow.warning.polishDegradedQuality" = "本次润色结果未通过校验,已插入保守处理版本。";
|
||||
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
|
||||
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
|
||||
@@ -982,6 +1103,35 @@
|
||||
"account.referral.pendingAfterSignIn" = "邀请已暂存,登录后会自动兑换。";
|
||||
"account.referral.rewardGranted" = "恭喜获得 %@ 积分";
|
||||
"account.referral.rewardGrantedGeneric" = "邀请奖励已发放,积分已到账。";
|
||||
"account.referral.poster.title" = "邀请好友";
|
||||
"account.referral.poster.accessibility" = "带有你的邀请链接二维码的邀请海报";
|
||||
"account.referral.poster.enlargeHint" = "全屏显示二维码,方便当面扫码。";
|
||||
"account.referral.poster.save" = "保存图片";
|
||||
"account.referral.poster.share" = "分享";
|
||||
"account.referral.poster.copyLink" = "复制链接";
|
||||
"account.referral.poster.footnote" = "把图片发给好友,或点海报放大二维码当面扫。好友注册并完成首次有效使用后,你们都能获得积分。";
|
||||
"account.referral.poster.saved" = "已保存到相册";
|
||||
"account.referral.poster.saveDenied" = "请在“设置”中允许 OSGKeyboard 添加照片,然后重试。";
|
||||
"account.referral.poster.saveFailed" = "图片保存失败,请重试。";
|
||||
"account.referral.poster.linkCopied" = "邀请链接已复制";
|
||||
"account.referral.codeEntry.title" = "有好友邀请?";
|
||||
"account.referral.codeEntry.body" = "输入好友的邀请码后登录,即可接受邀请";
|
||||
"account.referral.codeEntry.bodySignedIn" = "输入好友的邀请码,你们都能获得积分。";
|
||||
"account.referral.codeEntry.button" = "我有邀请码";
|
||||
"account.referral.codeEntry.windowDescription" = "输入邀请码,或邀请更多好友获取积分";
|
||||
"account.referral.codeEntry.sheetTitle" = "输入邀请码";
|
||||
"account.referral.codeEntry.sheetBody" = "输入好友海报或邀请页上的 8 位邀请码,也可以直接粘贴邀请链接。";
|
||||
"account.referral.codeEntry.placeholder" = "例如 K7M2-Q9XA";
|
||||
"account.referral.codeEntry.fieldLabel" = "邀请码";
|
||||
"account.referral.codeEntry.confirm" = "确认";
|
||||
"account.referral.codeEntry.invalid" = "没有识别到邀请码,请核对后重试。";
|
||||
"account.referral.codeEntry.redeemed" = "已接受好友邀请,完成一次有效使用后双方均可获得积分。";
|
||||
"account.referral.codeEntry.inviteMore" = "也可以邀请更多好友获取积分";
|
||||
"account.referral.redeem.error.invalid" = "邀请码无效,或活动已结束。";
|
||||
"account.referral.redeem.error.expired" = "这个账号已超过补填邀请的期限。";
|
||||
"account.referral.redeem.error.self" = "不能使用自己的邀请。";
|
||||
"account.referral.redeem.error.alreadyBound" = "这个账号已经接受过其他好友的邀请。";
|
||||
"account.referral.redeem.error.ineligible" = "这个账号暂时不能接受该邀请。";
|
||||
"account.security.section" = "账号操作";
|
||||
"account.signOut.action" = "退出登录";
|
||||
"account.signOut.confirmTitle" = "退出登录?";
|
||||
|
||||
@@ -106,6 +106,25 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// Regular × regular on a phone host is the unfolded iPhone Duo; every
|
||||
/// other iPhone posture has at least one compact axis.
|
||||
private var isExpandedPhoneLayout: Bool {
|
||||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||||
isPad: UIDevice.current.userInterfaceIdiom == .pad,
|
||||
hasRegularWidth: traitCollection.horizontalSizeClass == .regular,
|
||||
hasRegularHeight: traitCollection.verticalSizeClass == .regular
|
||||
)
|
||||
}
|
||||
|
||||
/// Compact height on a phone host is a landscape iPhone (folded Duo
|
||||
/// included). `.unspecified` before the first trait pass is not compact.
|
||||
private var isCompactHeightPhoneLayout: Bool {
|
||||
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(
|
||||
isPad: UIDevice.current.userInterfaceIdiom == .pad,
|
||||
hasCompactHeight: traitCollection.verticalSizeClass == .compact
|
||||
)
|
||||
}
|
||||
|
||||
/// Width the layout should be sized against. `view.bounds` is empty before
|
||||
/// the first layout pass, so fall back to the screen the keyboard is on.
|
||||
private var currentLayoutWidth: CGFloat {
|
||||
@@ -120,7 +139,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
KeyboardSurfaceRoot.height(
|
||||
for: state.surface,
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,7 +176,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
state.showsSystemGlobeKey = UIDevice.current.userInterfaceIdiom == .pad
|
||||
#if DEBUG
|
||||
HostBundleProbe.installLogging()
|
||||
#endif
|
||||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||||
primaryLanguage = "mis"
|
||||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||||
@@ -227,6 +250,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
isKeyboardPresentationActive = false
|
||||
HostAppIdentity.invalidate()
|
||||
typingDocumentPresentationID = nil
|
||||
typingSessionStorage?.endDocumentPresentation()
|
||||
assistantFieldActionRefreshTask?.cancel()
|
||||
@@ -265,9 +289,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
if !preserve {
|
||||
prepareSurfaceForNextPresentation()
|
||||
}
|
||||
if state.surface != .typing {
|
||||
deactivateTypingSurface()
|
||||
}
|
||||
// Always finalize librime on hide, even on the typing surface: its
|
||||
// LevelDB userdb holds `Rime/UserData/*.userdb/LOCK` in the App Group,
|
||||
// and RunningBoard kills a suspended extension holding that lock
|
||||
// (0xdead10cc). `viewWillAppear` re-prepares Rime (~30–50 ms).
|
||||
deactivateTypingSurface()
|
||||
if preserve {
|
||||
return
|
||||
}
|
||||
@@ -353,6 +379,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
|
||||
}
|
||||
#if DEBUG
|
||||
HostBundleProbe.recordPresentation()
|
||||
#endif
|
||||
// Host identification feeds the cold-start auto-return; runs in every build.
|
||||
HostAppIdentity.refreshOnPresentation()
|
||||
#if DEBUG
|
||||
WhatsNewDemoDriver.resetForNewPresentation()
|
||||
WhatsNewDemoDriver.startIfNeeded(
|
||||
state: state,
|
||||
@@ -397,7 +428,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass else {
|
||||
guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass
|
||||
|| previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass else {
|
||||
return
|
||||
}
|
||||
refreshLayoutMode()
|
||||
@@ -771,28 +803,38 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func installSurfaceObservers() {
|
||||
state.$phase
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
if self.state.locksTypingSurface, self.state.surface == .typing {
|
||||
self.applySurface(.voice)
|
||||
}
|
||||
let state = state
|
||||
// `Phase` is not Sendable; reading it only registers the dependency.
|
||||
observe(Observations { _ = state.phase }) { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.state.locksTypingSurface, self.state.surface == .typing {
|
||||
self.applySurface(.voice)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
state.$surface
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] surface in
|
||||
guard let self else { return }
|
||||
if surface == .typing {
|
||||
self.activateTypingPresentationIfNeeded()
|
||||
} else {
|
||||
self.deactivateTypingSurface()
|
||||
}
|
||||
self.refreshKeyboardHeight()
|
||||
observe(Observations { state.surface }) { [weak self] surface in
|
||||
guard let self else { return }
|
||||
if surface == .typing {
|
||||
self.activateTypingPresentationIfNeeded()
|
||||
} else {
|
||||
self.deactivateTypingSurface()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
self.refreshKeyboardHeight()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `handler` for the current value and every later change. The loop
|
||||
/// is cancelled together with `cancellables` when the controller goes away.
|
||||
private func observe<Element: Sendable>(
|
||||
_ values: Observations<Element, Never>,
|
||||
handler: @escaping @MainActor (Element) -> Void
|
||||
) {
|
||||
let task = Task { @MainActor in
|
||||
for await value in values {
|
||||
handler(value)
|
||||
}
|
||||
}
|
||||
cancellables.insert(AnyCancellable { task.cancel() })
|
||||
}
|
||||
|
||||
/// This App Group property is supplied by the parallel settings module.
|
||||
@@ -932,24 +974,76 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func refreshLayoutMode() {
|
||||
// Folding moves the keyboard between a display with a system switch
|
||||
// below it and one without, so re-read this on every layout pass.
|
||||
refreshInputModeSwitchKey()
|
||||
let usesIPadMetrics = isIPadLayout
|
||||
let usesExpandedPhoneMetrics = isExpandedPhoneLayout
|
||||
let usesCompactHeightPhoneMetrics = isCompactHeightPhoneLayout
|
||||
let width = currentLayoutWidth
|
||||
// `state.layoutWidth` tracks the width that last changed the layout
|
||||
// bucket, not every intermediate width: republishing on each frame of
|
||||
// a Stage Manager drag would rebuild the SwiftUI grid continuously.
|
||||
let bucketChanged = KeyboardChromeLayout.usesWideIPadMetrics(
|
||||
let bucketChanged = Self.layoutWidthBucket(
|
||||
isIPad: usesIPadMetrics,
|
||||
isExpandedPhone: usesExpandedPhoneMetrics,
|
||||
isCompactHeightPhone: usesCompactHeightPhoneMetrics,
|
||||
width: width
|
||||
) != KeyboardChromeLayout.usesWideIPadMetrics(
|
||||
) != Self.layoutWidthBucket(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
)
|
||||
guard state.usesIPadLayoutMetrics != usesIPadMetrics || bucketChanged else { return }
|
||||
guard state.usesIPadLayoutMetrics != usesIPadMetrics
|
||||
|| state.usesExpandedPhoneLayoutMetrics != usesExpandedPhoneMetrics
|
||||
|| state.usesCompactHeightPhoneLayoutMetrics != usesCompactHeightPhoneMetrics
|
||||
|| bucketChanged else { return }
|
||||
state.usesIPadLayoutMetrics = usesIPadMetrics
|
||||
state.usesExpandedPhoneLayoutMetrics = usesExpandedPhoneMetrics
|
||||
state.usesCompactHeightPhoneLayoutMetrics = usesCompactHeightPhoneMetrics
|
||||
state.layoutWidth = width
|
||||
refreshKeyboardHeight()
|
||||
}
|
||||
|
||||
/// Every width threshold the SwiftUI surfaces branch on. The first layout
|
||||
/// pass can report a transient width (0, or the folded display's), so a
|
||||
/// threshold missing here would keep that stale width for the session.
|
||||
private static func layoutWidthBucket(
|
||||
isIPad: Bool,
|
||||
isExpandedPhone: Bool,
|
||||
isCompactHeightPhone: Bool,
|
||||
width: CGFloat
|
||||
) -> LayoutWidthBucket {
|
||||
LayoutWidthBucket(
|
||||
wideIPad: KeyboardChromeLayout.usesWideIPadMetrics(isIPad: isIPad, width: width),
|
||||
voiceKeyRow: VoiceKeyRowLayout.isAvailable(
|
||||
usesIPadMetrics: isIPad,
|
||||
usesExpandedPhoneMetrics: isExpandedPhone,
|
||||
usesCompactHeightPhoneMetrics: isCompactHeightPhone,
|
||||
hostWidth: width
|
||||
),
|
||||
compactVoice: KeyboardChromeLayout.usesCompactVoiceMetrics(width: width),
|
||||
compactIPadVoice: KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: isIPad, width: width)
|
||||
)
|
||||
}
|
||||
|
||||
private struct LayoutWidthBucket: Equatable {
|
||||
let wideIPad: Bool
|
||||
let voiceKeyRow: Bool
|
||||
let compactVoice: Bool
|
||||
let compactIPadVoice: Bool
|
||||
}
|
||||
|
||||
private func refreshInputModeSwitchKey() {
|
||||
let shows = KeyboardChromeLayout.showsInputModeSwitchKey(
|
||||
isPad: UIDevice.current.userInterfaceIdiom == .pad,
|
||||
systemRequiresSwitchKey: needsInputModeSwitchKey
|
||||
)
|
||||
guard state.showsSystemGlobeKey != shows else { return }
|
||||
state.showsSystemGlobeKey = shows
|
||||
}
|
||||
|
||||
private var heightPhaseLog: String {
|
||||
switch heightPhase {
|
||||
case .idle: return "idle"
|
||||
@@ -1192,7 +1286,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
+ "view=\(Int(view.bounds.height)) "
|
||||
+ "host=\(Int(hosting?.view.bounds.height ?? -1)) "
|
||||
+ "constraint=\(Int(keyboardHeightConstraint?.constant ?? -1)) "
|
||||
+ "target=\(Int(targetKeyboardHeight))"
|
||||
+ "target=\(Int(targetKeyboardHeight)) "
|
||||
+ "width=\(Int(view.bounds.width)) "
|
||||
+ "sizeClass=\(traitCollection.horizontalSizeClass.rawValue)x"
|
||||
+ "\(traitCollection.verticalSizeClass.rawValue) "
|
||||
+ "needsGlobe=\(needsInputModeSwitchKey ? 1 : 0)"
|
||||
guard snapshot != lastLoggedLayoutSnapshot else { return }
|
||||
lastLoggedLayoutSnapshot = snapshot
|
||||
OSGDiag.log("KVC.layout \(snapshot)", category: "boot")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// OSGKeyboardExt-Bridging-Header.h
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Exposes extension-local Objective-C helpers to Swift.
|
||||
|
||||
#import "Utilities/OSGHostArbiterProbe.h"
|
||||
@@ -67,6 +67,7 @@ final class KeyboardFlowCoordinator {
|
||||
private var currentUtteranceId: UUID?
|
||||
private var currentStartDeadlineAt: TimeInterval?
|
||||
private var currentUtteranceRequest: FlowUtteranceRequest?
|
||||
private let polishLatencyCalibration = DictationPolishLatencyCalibration()
|
||||
private var audioPrimeID: UUID?
|
||||
private var audioPrimeCancelTask: Task<Void, Never>?
|
||||
private var editHostConfirmed = false
|
||||
@@ -471,6 +472,7 @@ final class KeyboardFlowCoordinator {
|
||||
if state.lastTranscript.isEmpty {
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||
}
|
||||
beginDictationProgressIfNeeded()
|
||||
startFlowResultWatchdog()
|
||||
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
|
||||
}
|
||||
@@ -801,6 +803,7 @@ final class KeyboardFlowCoordinator {
|
||||
finishLocalCancel()
|
||||
return
|
||||
}
|
||||
state.dictationProgress = nil
|
||||
let inserted = registerCancelledUtterance(utteranceID, kind: kind)
|
||||
if !inserted, isAwaitingFlowResult {
|
||||
state.phase = .processing
|
||||
@@ -1101,6 +1104,7 @@ final class KeyboardFlowCoordinator {
|
||||
onAIRecognitionStarted(currentUtteranceId)
|
||||
} else if currentUtteranceRequest?.isEdit != true {
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||
beginDictationProgressIfNeeded()
|
||||
}
|
||||
startFlowResultWatchdog()
|
||||
recomputeMicVoiceAvailability()
|
||||
@@ -1134,6 +1138,7 @@ final class KeyboardFlowCoordinator {
|
||||
state.lastTranscript = ""
|
||||
recomputeMicVoiceAvailability()
|
||||
FlowSessionBridge.markPiPArmAttempt()
|
||||
HostAppIdentity.publishReturnTarget()
|
||||
OSGDiag.log(
|
||||
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
|
||||
+ "\(OSGDiag.memoryTag())",
|
||||
@@ -2152,6 +2157,8 @@ final class KeyboardFlowCoordinator {
|
||||
state.lastTranscript = partial
|
||||
if result.resolvedUtteranceMode == .aiQuestion {
|
||||
onAITranscript(partial, result.utteranceId, result.status)
|
||||
} else if result.status == .rawReady {
|
||||
markDictationPolishing(characterCount: partial.count)
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -2159,6 +2166,43 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dictation progress
|
||||
|
||||
/// Starts the microphone progress fill for a dictation wait. Keeps an
|
||||
/// existing timeline for the same utterance so re-adoption does not rewind.
|
||||
private func beginDictationProgressIfNeeded() {
|
||||
guard let utteranceID = currentUtteranceId,
|
||||
currentUtteranceRequest?.isAIQuestion != true,
|
||||
currentUtteranceRequest?.isEdit != true,
|
||||
state.dictationProgress?.utteranceID != utteranceID else { return }
|
||||
state.dictationProgress = DictationProgressTimeline(
|
||||
utteranceID: utteranceID,
|
||||
transcribingStartedAt: Date().timeIntervalSince1970
|
||||
)
|
||||
}
|
||||
|
||||
private func markDictationPolishing(characterCount: Int) {
|
||||
guard var timeline = state.dictationProgress,
|
||||
timeline.polishingStartedAt == nil else { return }
|
||||
timeline.markPolishing(
|
||||
at: Date().timeIntervalSince1970,
|
||||
characterCount: characterCount,
|
||||
expectedSeconds: polishLatencyCalibration.expectedSeconds(
|
||||
forCharacterCount: characterCount
|
||||
)
|
||||
)
|
||||
state.dictationProgress = timeline
|
||||
}
|
||||
|
||||
private func recordDictationPolishLatency() {
|
||||
guard let timeline = state.dictationProgress,
|
||||
let polishStart = timeline.polishingStartedAt else { return }
|
||||
polishLatencyCalibration.record(
|
||||
observedSeconds: Date().timeIntervalSince1970 - polishStart,
|
||||
characterCount: timeline.polishCharacterCount
|
||||
)
|
||||
}
|
||||
|
||||
private func startFlowResultWatchdog(
|
||||
purpose: ResultWatchdogPurpose = .delivery
|
||||
) {
|
||||
@@ -2212,6 +2256,7 @@ final class KeyboardFlowCoordinator {
|
||||
self.completeAIResult(result)
|
||||
return
|
||||
}
|
||||
self.recordDictationPolishLatency()
|
||||
self.textInserter.handleFlowTranscript(
|
||||
TranscriptionDelivery(
|
||||
text: text,
|
||||
|
||||
@@ -547,12 +547,6 @@ final class KeyboardTextInserter {
|
||||
guard !Task.isCancelled else { return }
|
||||
state?.assistantInsertionSucceeded = false
|
||||
}
|
||||
let hint = ExtL10n.string("keyboard.edit.hint.available")
|
||||
editHintScheduler.show(
|
||||
message: hint,
|
||||
isPositive: true,
|
||||
duration: .seconds(10)
|
||||
)
|
||||
}
|
||||
|
||||
private func clearEditHintIfPositive() {
|
||||
|
||||
@@ -10,7 +10,7 @@ struct KeyboardSurfaceRoot: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Namespace private var keyboardTabSelectionNamespace
|
||||
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
var onInsert: (String) -> Void
|
||||
@@ -38,9 +38,16 @@ struct KeyboardSurfaceRoot: View {
|
||||
static func height(
|
||||
for surface: KeyboardState.Surface,
|
||||
isIPad: Bool = false,
|
||||
width: CGFloat = 0
|
||||
width: CGFloat = 0,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
|
||||
TypingSurfaceMetrics.contentHeight(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -27,18 +27,38 @@ enum TypingLayoutMetrics {
|
||||
|
||||
// MARK: - iPad (regular size class) metrics
|
||||
|
||||
static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
|
||||
TypingSurfaceMetrics.metrics(isIPad: isIPad, width: width)
|
||||
static func metrics(
|
||||
isIPad: Bool,
|
||||
width: CGFloat,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> TypingKeyLayoutBuilder.Metrics {
|
||||
TypingSurfaceMetrics.metrics(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
}
|
||||
|
||||
static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
|
||||
static func contentHeight(
|
||||
isIPad: Bool,
|
||||
width: CGFloat,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct TypingRootView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
var onInsert: (String) -> Void
|
||||
@@ -50,8 +70,18 @@ struct TypingRootView: View {
|
||||
/// Keys currently under a finger (grid-level touch pad, multi-touch).
|
||||
@State private var highlightedKeyIDs: Set<String> = []
|
||||
|
||||
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
|
||||
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
|
||||
static func totalHeight(
|
||||
isIPad: Bool = false,
|
||||
width: CGFloat = 0,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> CGFloat {
|
||||
TypingLayoutMetrics.contentHeight(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
}
|
||||
|
||||
private var palette: ThemePalette {
|
||||
@@ -95,7 +125,9 @@ struct TypingRootView: View {
|
||||
.frame(
|
||||
height: Self.totalHeight(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
)
|
||||
.background(Color.clear)
|
||||
@@ -569,7 +601,12 @@ struct TypingRootView: View {
|
||||
// keyboard. Using `size` here would compare the grid's own width to
|
||||
// its height (always landscape) and could pick a different bucket than
|
||||
// the height constraint, clipping the bottom row.
|
||||
let metrics = TypingLayoutMetrics.metrics(isIPad: isIPad, width: state.layoutWidth)
|
||||
let metrics = TypingLayoutMetrics.metrics(
|
||||
isIPad: isIPad,
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
let pageLabel = typing.page == .letters ? "123" : "ABC"
|
||||
let spaceLabel = typing.language == .chinese ? "空格" : "space"
|
||||
let returnLabel: String = {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// HostBundleProbe.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。
|
||||
// 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效;
|
||||
// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”。
|
||||
// 调试日志与首页测试卡片仍只在 DEBUG 编译。
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum HostAppIdentity {
|
||||
/// 最近一次识别到的宿主;识别不到、或宿主是 OSG 自己时为 nil。
|
||||
static var currentBundleId: String? {
|
||||
guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId,
|
||||
!HostAppURLRegistry.isOwnBundleId(bundleId) else {
|
||||
return nil
|
||||
}
|
||||
return bundleId
|
||||
}
|
||||
|
||||
/// 键盘弹出时主动让 arbiter 回调一次,避免宿主没变时拿不到最新 ID。
|
||||
static func refreshOnPresentation() {
|
||||
OSGHostArbiterProbe.pingArbiter()
|
||||
}
|
||||
|
||||
/// 跳转主 App(`startflow`)前调用:把当前宿主写进 App Group,主 App 就绪后据此跳回。
|
||||
/// 识别不到时写 nil,清掉上一次残留,避免跳回错误的 App。
|
||||
static func publishReturnTarget() {
|
||||
let bundleId = currentBundleId
|
||||
FlowSessionBridge.setPendingHostBundleId(bundleId)
|
||||
#if DEBUG
|
||||
let returnable = HostAppURLRegistry.lookup(bundleId: bundleId) != nil
|
||||
HostBundleProbeLog.append(
|
||||
event: "startflow",
|
||||
bundleId: bundleId,
|
||||
detail: returnable ? "returnable" : "no-return-url"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 键盘收起时作废识别结果,下次弹出由新回调重新填充。
|
||||
static func invalidate() {
|
||||
OSGHostArbiterProbe.invalidateCapturedBundleId()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。
|
||||
enum HostBundleProbe {
|
||||
/// 只需安装一次;回调可能来自非主线程,闭包必须是 nonisolated。
|
||||
static func installLogging() {
|
||||
OSGHostArbiterProbe.setCaptureHandler { @Sendable bundleId, detail in
|
||||
HostBundleProbeLog.append(event: "arbiter", bundleId: bundleId, detail: detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。
|
||||
static func recordPresentation() {
|
||||
HostBundleProbeLog.append(
|
||||
event: "appear",
|
||||
bundleId: nil,
|
||||
detail: OSGHostArbiterProbe.isInstalled ? "hook=installed" : "hook=missing"
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,36 @@
|
||||
// OSGHostArbiterProbe.h
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 宿主识别探针(Debug / TestFlight / Release 均编译):iOS 26.4+ 通过
|
||||
// _UIKeyboardArbiterClient 回调读取宿主 App 的 bundle ID,供冷启动后自动跳回。
|
||||
// 原理参考 editorss/KeyboardHostBundleID(MIT)。
|
||||
// 必须在 +load 安装,否则可能错过键盘首次切换宿主时的回调。
|
||||
// 所有私有符号运行时按字符串查找,任何一步失败都静默退化为“识别不到宿主”。
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 回调可能在 arbiter 的私有队列上触发,不保证主线程。
|
||||
typedef void (^OSGHostArbiterCaptureHandler)(NSString *_Nullable bundleId, NSString *_Nullable detail);
|
||||
|
||||
@interface OSGHostArbiterProbe : NSObject
|
||||
|
||||
/// 钩子是否已成功安装(私有类或方法不存在时为 NO)。
|
||||
@property (class, nonatomic, readonly) BOOL isInstalled;
|
||||
|
||||
/// 最近一次回调读到的宿主 bundle ID;回调读不到或已作废时为 nil。线程安全。
|
||||
@property (class, nonatomic, readonly, nullable) NSString *lastCapturedBundleId;
|
||||
|
||||
/// 作废上一次的识别结果(键盘收起时调用,避免把上一个宿主当成当前宿主)。
|
||||
+ (void)invalidateCapturedBundleId;
|
||||
|
||||
/// 每次 arbiter 回调都会调用;设置为 nil 取消。
|
||||
+ (void)setCaptureHandler:(nullable OSGHostArbiterCaptureHandler)handler;
|
||||
|
||||
/// 主动让 arbiter 检查连接,可能触发一次回调,但不保证。
|
||||
+ (void)pingArbiter;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,129 @@
|
||||
// OSGHostArbiterProbe.m
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 宿主识别探针(所有构建配置均编译),见头文件说明。所有私有符号都在运行时按字符串查找,
|
||||
// 任何一步找不到都静默放弃,并保证原始实现照常执行。
|
||||
|
||||
#import "OSGHostArbiterProbe.h"
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <objc/message.h>
|
||||
#import <objc/runtime.h>
|
||||
#import <os/lock.h>
|
||||
|
||||
static NSString *const kArbiterClientClassName = @"_UIKeyboardArbiterClient";
|
||||
static NSString *const kDestinationClassName = @"_UIKeyboardArbiterClientInputDestination";
|
||||
static NSString *const kEnabledSelectorName = @"enabled";
|
||||
static NSString *const kSharedClientSelectorName = @"automaticSharedArbiterClient";
|
||||
static NSString *const kCheckConnectionSelectorName = @"checkConnection";
|
||||
static NSString *const kKeyboardChangedSelectorName = @"queue_keyboardChanged:onComplete:";
|
||||
static NSString *const kSourceBundleIdKey = @"_sourceBundleIdentifier";
|
||||
|
||||
static os_unfair_lock sLock = OS_UNFAIR_LOCK_INIT;
|
||||
static OSGHostArbiterCaptureHandler _Nullable sHandler = nil;
|
||||
static NSString *_Nullable sLastCapturedBundleId = nil;
|
||||
static IMP _Nullable sOriginalKeyboardChangedIMP = NULL;
|
||||
static BOOL sInstalled = NO;
|
||||
|
||||
#pragma mark - Trampolines
|
||||
|
||||
// 替换 -[_UIKeyboardArbiterClientInputDestination queue_keyboardChanged:onComplete:]。
|
||||
// bundle ID 在参数 change 上,不在 self 上。
|
||||
static void OSGSwizzledKeyboardChanged(id self, SEL _cmd, id change, id completion) {
|
||||
NSString *bundleId = nil;
|
||||
if (change) {
|
||||
@try {
|
||||
id value = [change valueForKey:kSourceBundleIdKey];
|
||||
if ([value isKindOfClass:[NSString class]]) {
|
||||
bundleId = [(NSString *)value copy];
|
||||
}
|
||||
} @catch (__unused NSException *exception) {
|
||||
// 字段改名时 KVC 会抛异常;吞掉,保证原始实现继续执行。
|
||||
}
|
||||
}
|
||||
NSString *detail = change ? NSStringFromClass([change class]) : @"change=nil";
|
||||
|
||||
os_unfair_lock_lock(&sLock);
|
||||
// 读不到 ID 的回调同样覆盖旧值:宿主已经变了,旧 ID 不能再用。
|
||||
sLastCapturedBundleId = bundleId.length > 0 ? bundleId : nil;
|
||||
OSGHostArbiterCaptureHandler handler = sHandler;
|
||||
os_unfair_lock_unlock(&sLock);
|
||||
if (handler) {
|
||||
handler(bundleId, detail);
|
||||
}
|
||||
|
||||
if (sOriginalKeyboardChangedIMP) {
|
||||
((void (*)(id, SEL, id, id))sOriginalKeyboardChangedIMP)(self, _cmd, change, completion);
|
||||
}
|
||||
}
|
||||
|
||||
// 替换 +[_UIKeyboardArbiterClient enabled]:iOS 26.4 用它控制是否派发上面的回调。
|
||||
static BOOL OSGAlwaysEnabled(__unused id self, __unused SEL _cmd) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
@implementation OSGHostArbiterProbe
|
||||
|
||||
+ (void)load {
|
||||
if (@available(iOS 26.4, *)) {
|
||||
[self installHooks];
|
||||
}
|
||||
}
|
||||
|
||||
+ (void)installHooks {
|
||||
Class clientClass = NSClassFromString(kArbiterClientClassName);
|
||||
if (clientClass) {
|
||||
Method enabled = class_getClassMethod(clientClass, NSSelectorFromString(kEnabledSelectorName));
|
||||
if (enabled) {
|
||||
method_setImplementation(enabled, (IMP)&OSGAlwaysEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
Class destinationClass = NSClassFromString(kDestinationClassName);
|
||||
if (!destinationClass) return;
|
||||
Method keyboardChanged = class_getInstanceMethod(
|
||||
destinationClass,
|
||||
NSSelectorFromString(kKeyboardChangedSelectorName)
|
||||
);
|
||||
if (!keyboardChanged) return;
|
||||
sOriginalKeyboardChangedIMP = method_getImplementation(keyboardChanged);
|
||||
method_setImplementation(keyboardChanged, (IMP)&OSGSwizzledKeyboardChanged);
|
||||
sInstalled = YES;
|
||||
}
|
||||
|
||||
+ (BOOL)isInstalled {
|
||||
return sInstalled;
|
||||
}
|
||||
|
||||
+ (NSString *)lastCapturedBundleId {
|
||||
os_unfair_lock_lock(&sLock);
|
||||
NSString *bundleId = [sLastCapturedBundleId copy];
|
||||
os_unfair_lock_unlock(&sLock);
|
||||
return bundleId;
|
||||
}
|
||||
|
||||
+ (void)invalidateCapturedBundleId {
|
||||
os_unfair_lock_lock(&sLock);
|
||||
sLastCapturedBundleId = nil;
|
||||
os_unfair_lock_unlock(&sLock);
|
||||
}
|
||||
|
||||
+ (void)setCaptureHandler:(OSGHostArbiterCaptureHandler)handler {
|
||||
os_unfair_lock_lock(&sLock);
|
||||
sHandler = [handler copy];
|
||||
os_unfair_lock_unlock(&sLock);
|
||||
}
|
||||
|
||||
+ (void)pingArbiter {
|
||||
Class clientClass = NSClassFromString(kArbiterClientClassName);
|
||||
SEL sharedSelector = NSSelectorFromString(kSharedClientSelectorName);
|
||||
if (!clientClass || ![clientClass respondsToSelector:sharedSelector]) return;
|
||||
|
||||
id client = ((id (*)(id, SEL))objc_msgSend)(clientClass, sharedSelector);
|
||||
SEL checkSelector = NSSelectorFromString(kCheckConnectionSelectorName);
|
||||
if (client && [client respondsToSelector:checkSelector]) {
|
||||
((void (*)(id, SEL))objc_msgSend)(client, checkSelector);
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AIKeyboardView: View {
|
||||
private enum Layout {
|
||||
@@ -21,7 +22,9 @@ struct AIKeyboardView: View {
|
||||
static let compactIPadCircleSize: CGFloat = 44
|
||||
static let sideButtonEdgeInset: CGFloat = 8
|
||||
static let actionClusterMaxWidth: CGFloat = 430
|
||||
static let primaryToSecondaryGap: CGFloat = 25
|
||||
static let surfaceVerticalPadding: CGFloat = 4
|
||||
/// The 3-line live transcript plus a little air above and below it.
|
||||
static let minimumContextHeight: CGFloat = statusViewportHeight + 12
|
||||
static let hotwordHeight: CGFloat = 28
|
||||
static let hotwordMaxWidth: CGFloat = 180
|
||||
static let skillTipMaxWidth: CGFloat = 300
|
||||
@@ -29,12 +32,7 @@ struct AIKeyboardView: View {
|
||||
/// belongs visually to the label-to-tab gap, so compensate by 3.5 pt.
|
||||
static let contextVisualOffset: CGFloat = -3.5
|
||||
static let carouselInterval: TimeInterval = 4
|
||||
static let skillButtonSize: CGFloat = 48
|
||||
static let skillCellWidth: CGFloat = 60
|
||||
static let skillCellSpacing: CGFloat = 10
|
||||
static let pageDotSize: CGFloat = 5
|
||||
static let skillPaginationBottomInset: CGFloat = 6
|
||||
static let maximumSemanticSkills = 5
|
||||
static let statusViewportHeight = UIFont.systemFont(ofSize: 15).lineHeight * 3
|
||||
}
|
||||
|
||||
private struct SemanticBadgeContent {
|
||||
@@ -50,7 +48,8 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
|
||||
/// Top-bar skill capsules for `--ai-skills-demo` / harness / demo recordings.
|
||||
/// Nil keeps production clipboard-window gating.
|
||||
static var debugPreviewSkills: [AIClipboardSkill]?
|
||||
/// Deterministic intent/domain labels for the assistant UI harness.
|
||||
static var debugPreviewSemanticBadgeKeys: (intent: String?, domain: String?)?
|
||||
@@ -62,7 +61,7 @@ struct AIKeyboardView: View {
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
|
||||
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
|
||||
@@ -75,7 +74,6 @@ struct AIKeyboardView: View {
|
||||
@State private var hintOpacity: Double = 1
|
||||
@State private var carouselBag = AIHintCarouselBag()
|
||||
@State private var poolCards: [AIHintCard] = []
|
||||
@State private var selectedSkillPage = 0
|
||||
@State private var debugSkillsDismissed = false
|
||||
@State private var micLongPressConsumed = false
|
||||
@State private var micIsHoldingForAI = false
|
||||
@@ -122,16 +120,11 @@ struct AIKeyboardView: View {
|
||||
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in
|
||||
state.dismissedClipboardSkillEntryID = nil
|
||||
debugSkillsDismissed = false
|
||||
selectedSkillPage = 0
|
||||
resetCarousel()
|
||||
}
|
||||
.onChange(of: state.clipboardSkillCatalog) { _, _ in
|
||||
selectedSkillPage = 0
|
||||
resetCarousel()
|
||||
}
|
||||
.onChange(of: semanticRanking.snapshot) { _, _ in
|
||||
selectedSkillPage = 0
|
||||
}
|
||||
.onChange(of: state.skillTipText) { _, tip in
|
||||
guard let tip, !tip.isEmpty else { return }
|
||||
#if DEBUG
|
||||
@@ -150,7 +143,7 @@ struct AIKeyboardView: View {
|
||||
) { _ in
|
||||
guard assistantIsResting else { return }
|
||||
reloadHintPool(resetBag: false)
|
||||
guard !showsClipboardSkills, !showsLongPressCoach else { return }
|
||||
guard !showsLongPressCoach else { return }
|
||||
if reduceMotion, let hint = currentHint,
|
||||
poolCards.contains(where: { $0.id == hint.id }) {
|
||||
return
|
||||
@@ -176,13 +169,17 @@ struct AIKeyboardView: View {
|
||||
contextArea
|
||||
.frame(maxHeight: .infinity)
|
||||
.offset(y: Layout.contextVisualOffset)
|
||||
primaryActionRow.frame(height: Layout.primaryHeight)
|
||||
Color.clear.frame(height: Layout.primaryToSecondaryGap)
|
||||
secondaryActionRow.frame(height: Layout.secondaryHeight)
|
||||
primaryActionRow.frame(height: primaryRowHeight)
|
||||
Color.clear.frame(height: primaryToSecondaryGap)
|
||||
if state.usesVoiceKeyRow {
|
||||
voiceKeyRow.frame(height: voiceKeyRowHeight)
|
||||
} else {
|
||||
secondaryActionRow.frame(height: Layout.secondaryHeight)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, Layout.surfaceVerticalPadding)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
||||
.frame(maxWidth: voiceContentWidthCap)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: resolvedHeight)
|
||||
}
|
||||
@@ -231,9 +228,9 @@ struct AIKeyboardView: View {
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, Layout.surfaceVerticalPadding)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
||||
.frame(maxWidth: voiceContentWidthCap)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: resolvedHeight)
|
||||
}
|
||||
@@ -286,9 +283,9 @@ struct AIKeyboardView: View {
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.accessibilityIdentifier("assistant.replyVariants")
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, Layout.surfaceVerticalPadding)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
||||
.frame(maxWidth: voiceContentWidthCap)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: resolvedHeight)
|
||||
}
|
||||
@@ -346,10 +343,48 @@ struct AIKeyboardView: View {
|
||||
private var resolvedHeight: CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
}
|
||||
|
||||
private var primaryRowHeight: CGFloat {
|
||||
state.usesCompactHeightPhoneLayoutMetrics ? Layout.compactPrimaryHeight : Layout.primaryHeight
|
||||
}
|
||||
|
||||
/// Gap below the mic. The context slot above it is flexible, so whatever
|
||||
/// this gap doesn't take piles up there; `VoiceMicPlacement` splits the
|
||||
/// free height so the mic sits centred on every shell height.
|
||||
private var primaryToSecondaryGap: CGFloat {
|
||||
let bottomRow = state.usesVoiceKeyRow ? voiceKeyRowHeight : Layout.secondaryHeight
|
||||
let contextAndGap = resolvedHeight
|
||||
- 2 * Layout.surfaceVerticalPadding
|
||||
- Layout.topBarHeight
|
||||
- primaryRowHeight
|
||||
- bottomRow
|
||||
let context = VoiceMicPlacement.contextHeight(
|
||||
contextAndGap: contextAndGap,
|
||||
bottomRowHeight: bottomRow,
|
||||
minimumContextHeight: Layout.minimumContextHeight
|
||||
)
|
||||
return max(0, contextAndGap - context)
|
||||
}
|
||||
|
||||
private var voiceContentWidthCap: CGFloat {
|
||||
KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: state.usesVoiceKeyRow)
|
||||
}
|
||||
|
||||
/// Same height as the typing bottom row so the two rows coincide.
|
||||
private var voiceKeyRowHeight: CGFloat {
|
||||
TypingSurfaceMetrics.metrics(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
).bottomRowHeight
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
|
||||
@ViewBuilder
|
||||
@@ -373,16 +408,9 @@ struct AIKeyboardView: View {
|
||||
hintKey: "keyboard.voice.cancelHint"
|
||||
)
|
||||
} else if showsClipboardSkillBar {
|
||||
// Fresh copy, no AI result yet: the top bar becomes the skill entry
|
||||
// point (paste + every applicable skill + dismiss).
|
||||
// Fresh copy (or an OOBE lesson), no AI result yet: the top bar becomes
|
||||
// the only skill entry point (paste + every applicable skill + dismiss).
|
||||
clipboardSkillTopBar
|
||||
} else if showsClipboardSkills {
|
||||
// OOBE / debug chip pager keeps its dismiss bar.
|
||||
cancelTopBar(
|
||||
action: dismissClipboardPresentation,
|
||||
labelKey: "keyboard.assistant.dismissClipboard",
|
||||
hintKey: "keyboard.assistant.dismissClipboardHint"
|
||||
)
|
||||
} else {
|
||||
ZStack {
|
||||
KeyboardTopControls(
|
||||
@@ -403,28 +431,34 @@ struct AIKeyboardView: View {
|
||||
/// Fresh-copy top bar (no active result): paste + scrollable skills + dismiss.
|
||||
private var clipboardSkillTopBar: some View {
|
||||
HStack(spacing: 6) {
|
||||
leadingScrollRow
|
||||
KeyboardCancelButton(
|
||||
action: dismissClipboardPresentation,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.assistant.dismissClipboard"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.assistant.dismissClipboardHint"),
|
||||
accessibilityIdentifier: "assistant.clipboard.dismiss"
|
||||
)
|
||||
leadingScrollRow(skills: restingBarSkills)
|
||||
// An OOBE lesson has no real copy to dismiss; its skill stays put.
|
||||
if !showsOOBEClipboardSkill {
|
||||
KeyboardCancelButton(
|
||||
action: dismissClipboardPresentation,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.assistant.dismissClipboard"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.assistant.dismissClipboardHint"),
|
||||
accessibilityIdentifier: "assistant.clipboard.dismiss"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
/// Fresh copy with applicable skills and no active AI result to defer to.
|
||||
private var showsClipboardSkillBar: Bool {
|
||||
guard assistantIsResting,
|
||||
state.oobePracticeSession == nil,
|
||||
guard assistantIsResting, !restingBarSkills.isEmpty else { return false }
|
||||
#if DEBUG
|
||||
if Self.debugPreviewSkills?.isEmpty == false { return true }
|
||||
#endif
|
||||
if showsOOBEClipboardSkill { return true }
|
||||
guard state.oobePracticeSession == nil,
|
||||
state.clipboardHistoryEnabled,
|
||||
state.aiServiceAvailable,
|
||||
!state.aiSession.canInsert,
|
||||
!state.aiSession.canSelectReplyVariant,
|
||||
let newest = clipboardHistory.newestEntry,
|
||||
newest.id != state.dismissedClipboardSkillEntryID,
|
||||
!topBarSkills.isEmpty else {
|
||||
newest.id != state.dismissedClipboardSkillEntryID else {
|
||||
return false
|
||||
}
|
||||
return AIHintPool.isClipboardSkillWindowActive(
|
||||
@@ -468,7 +502,7 @@ struct AIKeyboardView: View {
|
||||
) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
// Paste + every applicable skill scroll together; the X stays put.
|
||||
leadingScrollRow
|
||||
leadingScrollRow(skills: topBarSkills)
|
||||
KeyboardCancelButton(
|
||||
action: action,
|
||||
accessibilityLabel: ExtL10n.text(labelKey),
|
||||
@@ -484,9 +518,8 @@ struct AIKeyboardView: View {
|
||||
/// Paste capsule followed by every applicable skill — so Paste scrolls along
|
||||
/// with the skills instead of staying pinned.
|
||||
@ViewBuilder
|
||||
private var leadingScrollRow: some View {
|
||||
private func leadingScrollRow(skills: [AIClipboardSkill]) -> some View {
|
||||
let paste = shouldShowClipboardSuggestion
|
||||
let skills = topBarSkills
|
||||
HStack(spacing: 6) {
|
||||
if !paste {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
@@ -532,6 +565,20 @@ struct AIKeyboardView: View {
|
||||
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
||||
}
|
||||
|
||||
/// Capsules for the resting skill bar: the DEBUG preview, the one skill an
|
||||
/// OOBE lesson practises, or the ranked production list.
|
||||
private var restingBarSkills: [AIClipboardSkill] {
|
||||
#if DEBUG
|
||||
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
|
||||
return debugSkillsDismissed ? [] : preview
|
||||
}
|
||||
#endif
|
||||
if let expectedSkillID = oobeExpectedSkillID {
|
||||
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
|
||||
}
|
||||
return topBarSkills
|
||||
}
|
||||
|
||||
/// Skills that apply to the current copy, most-relevant first. This is the
|
||||
/// sole skill entry point now that the circular chips are gone; it shows with
|
||||
/// or without an active result (the invoked skill is excluded when active).
|
||||
@@ -570,7 +617,8 @@ struct AIKeyboardView: View {
|
||||
sourceText: newest.text,
|
||||
analysis: snapshot.analysis,
|
||||
uiLanguage: state.uiLanguage,
|
||||
limit: 6
|
||||
limit: 6,
|
||||
translationTargetLocaleId: state.translationTargetLocaleId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -602,9 +650,7 @@ struct AIKeyboardView: View {
|
||||
@ViewBuilder
|
||||
private var contextArea: some View {
|
||||
ZStack {
|
||||
if showsOOBEClipboardSkill, activeStatus == nil {
|
||||
clipboardSkillPager
|
||||
} else if let onboardingPracticeHint, activeStatus == nil {
|
||||
if let onboardingPracticeHint, !hasActiveStatus {
|
||||
Text(onboardingPracticeHint)
|
||||
.font(TypeStyle.footnote.weight(.semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -625,17 +671,14 @@ struct AIKeyboardView: View {
|
||||
.glassEffect(.regular, in: Capsule())
|
||||
}
|
||||
.accessibilityIdentifier("assistant.skillTip")
|
||||
} else if showsClipboardSkills {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
if let content = semanticBadgeContent {
|
||||
semanticBadge(content)
|
||||
}
|
||||
clipboardSkillPager
|
||||
}
|
||||
} else if let content = semanticBadgeContent {
|
||||
semanticBadge(content)
|
||||
} else if let status = activeStatus {
|
||||
statusText(status.text, color: status.color)
|
||||
} else if hasActiveStatus {
|
||||
LiveTranscriptReader(state: state) { transcript in
|
||||
if let status = activeStatus(lastTranscript: transcript) {
|
||||
statusText(status.text, color: status.color)
|
||||
}
|
||||
}
|
||||
} else if showsLongPressCoach {
|
||||
Text(ExtL10n.string("keyboard.assistant.longPressCoach"))
|
||||
.font(TypeStyle.footnote.weight(.medium))
|
||||
@@ -685,16 +728,47 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
|
||||
private func statusText(_ text: String, color: Color) -> some View {
|
||||
Text(text)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity)
|
||||
GeometryReader { geometry in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
// Match the viewport height while short, then grow upward one
|
||||
// complete line at a time as the live transcript wraps.
|
||||
Text(text)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(color)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: geometry.size.height,
|
||||
alignment: .center
|
||||
)
|
||||
.id("assistant.status.text")
|
||||
}
|
||||
.scrollDisabled(true)
|
||||
.scrollEdgeEffectHidden()
|
||||
.onAppear {
|
||||
proxy.scrollTo("assistant.status.text", anchor: .bottom)
|
||||
}
|
||||
.onChange(of: text) { _, _ in
|
||||
// ASR partials update per word. Jump only by the resulting
|
||||
// layout delta instead of animating every partial update.
|
||||
proxy.scrollTo("assistant.status.text", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: Layout.statusViewportHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var activeStatus: (text: String, color: Color)? {
|
||||
/// Whether a status line replaces the idle content. Never depends on the
|
||||
/// transcript text, so this surface does not re-render per ASR partial.
|
||||
private var hasActiveStatus: Bool {
|
||||
activeStatus(lastTranscript: "") != nil
|
||||
}
|
||||
|
||||
/// `lastTranscript` is passed in so only `LiveTranscriptReader` tracks it.
|
||||
private func activeStatus(lastTranscript: String) -> (text: String, color: Color)? {
|
||||
if let error = state.aiSession.errorMessage,
|
||||
!error.isEmpty,
|
||||
!ordinaryPipelineIsActive {
|
||||
@@ -731,18 +805,18 @@ struct AIKeyboardView: View {
|
||||
return nil
|
||||
case .requestingPermissions:
|
||||
return (
|
||||
state.lastTranscript.isEmpty
|
||||
lastTranscript.isEmpty
|
||||
? ExtL10n.string("keyboard.placeholder.preparing")
|
||||
: state.lastTranscript,
|
||||
: lastTranscript,
|
||||
palette.textSecondary
|
||||
)
|
||||
case .recording:
|
||||
return (state.lastTranscript.isEmpty ? " " : state.lastTranscript, palette.textPrimary)
|
||||
return (lastTranscript.isEmpty ? " " : lastTranscript, palette.textPrimary)
|
||||
case .processing:
|
||||
return (
|
||||
state.lastTranscript.isEmpty
|
||||
lastTranscript.isEmpty
|
||||
? ExtL10n.string("keyboard.placeholder.processing")
|
||||
: state.lastTranscript,
|
||||
: lastTranscript,
|
||||
palette.textSecondary
|
||||
)
|
||||
case .error(_, let message):
|
||||
@@ -811,74 +885,6 @@ struct AIKeyboardView: View {
|
||||
|
||||
// MARK: - Clipboard skills
|
||||
|
||||
private var clipboardSkillPager: some View {
|
||||
GeometryReader { proxy in
|
||||
let skillsPerPage = proxy.size.width < 352 ? 4 : 5
|
||||
let pages = skillPages(from: visibleClipboardSkills, count: skillsPerPage)
|
||||
VStack(spacing: 5) {
|
||||
TabView(selection: $selectedSkillPage) {
|
||||
ForEach(Array(pages.enumerated()), id: \.offset) { page, skills in
|
||||
HStack(spacing: Layout.skillCellSpacing) {
|
||||
ForEach(skills) { skill in
|
||||
skillChip(skill)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.tag(page)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.accessibilityIdentifier("assistant.skills.pager")
|
||||
|
||||
HStack(spacing: Layout.pageDotSize) {
|
||||
ForEach(pages.indices, id: \.self) { page in
|
||||
Circle()
|
||||
.fill(page == selectedSkillPage
|
||||
? palette.textPrimary
|
||||
: palette.textTertiary.opacity(0.45))
|
||||
.frame(width: Layout.pageDotSize, height: Layout.pageDotSize)
|
||||
}
|
||||
}
|
||||
.opacity(pages.count > 1 ? 1 : 0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(.bottom, Layout.skillPaginationBottomInset)
|
||||
}
|
||||
}
|
||||
|
||||
private func skillPages(
|
||||
from skills: [AIClipboardSkill],
|
||||
count: Int
|
||||
) -> [[AIClipboardSkill]] {
|
||||
stride(from: 0, to: skills.count, by: count).map { start in
|
||||
Array(skills[start ..< min(start + count, skills.count)])
|
||||
}
|
||||
}
|
||||
|
||||
private func skillChip(_ skill: AIClipboardSkill) -> some View {
|
||||
Button {
|
||||
state.submitAIClipboardSkill(skill, replyScene(for: skill))
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: skill.systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary.opacity(0.85))
|
||||
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
|
||||
.glassEffect(.regular.interactive(), in: Circle())
|
||||
Text(clipboardSkillTitle(skill))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.frame(width: Layout.skillCellWidth)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
|
||||
.accessibilityIdentifier("assistant.skill.\(skill.id)")
|
||||
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
||||
}
|
||||
|
||||
private func replyScene(for skill: AIClipboardSkill) -> AIClipboardReplyScene? {
|
||||
guard skill.id == AIClipboardSkillCatalog.replyID,
|
||||
state.oobePracticeSession == nil,
|
||||
@@ -900,51 +906,17 @@ struct AIKeyboardView: View {
|
||||
!custom.isEmpty {
|
||||
return custom
|
||||
}
|
||||
// "Translate to <target>" names the user's configured target language.
|
||||
if skill.id == AIClipboardSkillCatalog.translateToTargetID,
|
||||
let title = AIClipboardSkillCatalog.translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: state.translationTargetLocaleId,
|
||||
uiLanguage: state.uiLanguage
|
||||
) {
|
||||
return title
|
||||
}
|
||||
return ExtL10n.string(skill.titleKey)
|
||||
}
|
||||
|
||||
private var showsClipboardSkills: Bool {
|
||||
#if DEBUG
|
||||
if let preview = Self.debugPreviewSkills,
|
||||
!preview.isEmpty,
|
||||
!debugSkillsDismissed {
|
||||
return assistantIsResting
|
||||
}
|
||||
#endif
|
||||
if showsOOBEClipboardSkill {
|
||||
return assistantIsResting
|
||||
}
|
||||
// The idle circular skill chips are retired: auto mode runs one skill and
|
||||
// the result panel's top-bar capsules offer the rest. Only OOBE / debug
|
||||
// still present the chip pager above.
|
||||
return false
|
||||
}
|
||||
|
||||
private var visibleClipboardSkills: [AIClipboardSkill] {
|
||||
#if DEBUG
|
||||
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
|
||||
return preview
|
||||
}
|
||||
#endif
|
||||
if let expectedSkillID = oobeExpectedSkillID {
|
||||
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
|
||||
}
|
||||
guard let newest = clipboardHistory.newestEntry else { return [] }
|
||||
guard let snapshot = semanticRanking.snapshot,
|
||||
snapshot.entryID == newest.id else {
|
||||
return state.clipboardSkillCatalog.filter {
|
||||
$0.id == AIClipboardSkillCatalog.replyID
|
||||
}
|
||||
}
|
||||
return ClipboardSkillSemanticRanker.recommended(
|
||||
skills: state.clipboardSkillCatalog,
|
||||
sourceText: newest.text,
|
||||
analysis: snapshot.analysis,
|
||||
uiLanguage: state.uiLanguage,
|
||||
limit: Layout.maximumSemanticSkills
|
||||
)
|
||||
}
|
||||
|
||||
private var showsOOBEClipboardSkill: Bool {
|
||||
oobeExpectedSkillID != nil
|
||||
}
|
||||
@@ -981,16 +953,19 @@ struct AIKeyboardView: View {
|
||||
|
||||
private var primaryActionRow: some View {
|
||||
ZStack {
|
||||
HStack {
|
||||
primarySideButton(leading: true)
|
||||
.offset(x: Layout.sideButtonEdgeInset)
|
||||
Spacer(minLength: 0)
|
||||
primarySideButton(leading: false)
|
||||
.offset(x: -Layout.sideButtonEdgeInset)
|
||||
// The key row already carries space / delete, so the mic stands alone.
|
||||
if !state.usesVoiceKeyRow {
|
||||
HStack {
|
||||
primarySideButton(leading: true)
|
||||
.offset(x: Layout.sideButtonEdgeInset)
|
||||
Spacer(minLength: 0)
|
||||
primarySideButton(leading: false)
|
||||
.offset(x: -Layout.sideButtonEdgeInset)
|
||||
}
|
||||
.opacity(sideButtonsVisible ? 1 : 0)
|
||||
.allowsHitTesting(sideButtonsVisible)
|
||||
.accessibilityHidden(!sideButtonsVisible)
|
||||
}
|
||||
.opacity(sideButtonsVisible ? 1 : 0)
|
||||
.allowsHitTesting(sideButtonsVisible)
|
||||
.accessibilityHidden(!sideButtonsVisible)
|
||||
|
||||
microphoneButton
|
||||
}
|
||||
@@ -998,11 +973,16 @@ struct AIKeyboardView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
/// Handedness rule shared by the circle cluster and the key row: space and
|
||||
/// edit share one side, delete and undo the other.
|
||||
private func placesSpaceAndEdit(leading: Bool) -> Bool {
|
||||
let swapped = state.handednessPreference.swapsActionKeys
|
||||
return leading ? swapped : !swapped
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func primarySideButton(leading: Bool) -> some View {
|
||||
let swapped = state.handednessPreference.swapsActionKeys
|
||||
let showsSpace = leading ? swapped : !swapped
|
||||
if showsSpace {
|
||||
if placesSpaceAndEdit(leading: leading) {
|
||||
circleActionButton(
|
||||
systemName: "space",
|
||||
label: ExtL10n.string("keyboard.assistant.space"),
|
||||
@@ -1022,6 +1002,9 @@ struct AIKeyboardView: View {
|
||||
.regular.tint(microphoneTint).interactive(),
|
||||
in: Capsule()
|
||||
)
|
||||
if let timeline = activeDictationProgress {
|
||||
dictationProgressFill(timeline)
|
||||
}
|
||||
microphoneContent
|
||||
}
|
||||
.frame(width: primaryButtonWidth, height: primaryButtonHeight)
|
||||
@@ -1070,11 +1053,41 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dictation wait rendered as a fill; AI phases keep their own spinner.
|
||||
private var activeDictationProgress: DictationProgressTimeline? {
|
||||
guard state.phase == .processing, !state.aiSession.isBusy else { return nil }
|
||||
return state.dictationProgress
|
||||
}
|
||||
|
||||
/// Leading-edge fill in the brand colour. The real result swaps the
|
||||
/// capsule to the solid accent success state, which reads as reaching 100 %.
|
||||
private func dictationProgressFill(_ timeline: DictationProgressTimeline) -> some View {
|
||||
TimelineView(.animation) { context in
|
||||
let fraction = DictationProgressEstimator.fraction(
|
||||
for: timeline,
|
||||
at: context.date.timeIntervalSince1970
|
||||
)
|
||||
GeometryReader { proxy in
|
||||
Rectangle()
|
||||
.fill(palette.accent)
|
||||
.frame(width: proxy.size.width * fraction)
|
||||
}
|
||||
}
|
||||
.clipShape(Capsule())
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var ordinaryMicrophoneContent: some View {
|
||||
switch state.phase {
|
||||
case .recording:
|
||||
waveform(color: OSGColor.recordingWaveform)
|
||||
case .processing where activeDictationProgress != nil:
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 25, weight: .bold))
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
case .requestingPermissions, .processing:
|
||||
ProgressView().tint(OSGColor.fixedLightContent)
|
||||
case .error:
|
||||
@@ -1090,14 +1103,7 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
|
||||
private func waveform(color: Color) -> some View {
|
||||
WaveformView(
|
||||
level: state.level,
|
||||
barCount: 7,
|
||||
color: color,
|
||||
active: true
|
||||
)
|
||||
.frame(width: 38, height: 26)
|
||||
.clipped()
|
||||
AssistantWaveform(state: state, color: color)
|
||||
}
|
||||
|
||||
private var microphoneTint: Color {
|
||||
@@ -1120,6 +1126,9 @@ struct AIKeyboardView: View {
|
||||
switch state.phase {
|
||||
case .recording:
|
||||
return palette.recordRed
|
||||
case .processing where activeDictationProgress != nil:
|
||||
// Unfilled track of the progress fill.
|
||||
return palette.accent.opacity(0.35)
|
||||
case .requestingPermissions, .processing:
|
||||
return palette.recordRed.opacity(0.72)
|
||||
case .error, .denied:
|
||||
@@ -1159,13 +1168,14 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
|
||||
private var primaryButtonWidth: CGFloat {
|
||||
state.layoutWidth > 0 && state.layoutWidth < 350
|
||||
KeyboardChromeLayout.usesCompactVoiceMetrics(width: state.layoutWidth)
|
||||
? Layout.compactPrimaryWidth
|
||||
: Layout.primaryWidth
|
||||
}
|
||||
|
||||
private var primaryButtonHeight: CGFloat {
|
||||
state.layoutWidth > 0 && state.layoutWidth < 350
|
||||
KeyboardChromeLayout.usesCompactVoiceMetrics(width: state.layoutWidth)
|
||||
|| state.usesCompactHeightPhoneLayoutMetrics
|
||||
? Layout.compactPrimaryHeight
|
||||
: Layout.primaryHeight
|
||||
}
|
||||
@@ -1247,38 +1257,17 @@ struct AIKeyboardView: View {
|
||||
.accessibilityHidden(activePipeline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
/// No globe here: switching input modes stays on the typing tab.
|
||||
private func lowerActionCluster(leading: Bool) -> some View {
|
||||
let button = lowerSideButton(leading: leading)
|
||||
lowerSideButton(leading: leading)
|
||||
.offset(x: leading
|
||||
? Layout.sideButtonEdgeInset
|
||||
: -Layout.sideButtonEdgeInset)
|
||||
if state.showsSystemGlobeKey {
|
||||
if leading {
|
||||
HStack(spacing: 8) {
|
||||
SystemGlobeKey(
|
||||
state: state,
|
||||
width: compactIPadLayout ? 40 : 44,
|
||||
height: lowerCircleSize
|
||||
)
|
||||
button
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
button
|
||||
Color.clear.frame(width: compactIPadLayout ? 40 : 44)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lowerSideButton(leading: Bool) -> some View {
|
||||
let swapped = state.handednessPreference.swapsActionKeys
|
||||
let showsEdit = leading ? swapped : !swapped
|
||||
if showsEdit {
|
||||
if placesSpaceAndEdit(leading: leading) {
|
||||
circleActionButton(
|
||||
systemName: "pencil.line",
|
||||
label: ExtL10n.string("keyboard.edit.apply"),
|
||||
@@ -1356,12 +1345,123 @@ struct AIKeyboardView: View {
|
||||
: Layout.fieldActionWidth
|
||||
}
|
||||
|
||||
// MARK: - Key row (iPad, unfolded iPhone Duo)
|
||||
|
||||
/// `[space][edit][ send ][undo][delete]` for a right-handed setting;
|
||||
/// handedness mirrors the row. Every key calls the same action as its phone
|
||||
/// circle — only placement and key face differ. No globe here: switching
|
||||
/// input modes stays on the typing tab.
|
||||
private var voiceKeyRow: some View {
|
||||
GeometryReader { proxy in
|
||||
let row = VoiceKeyRowLayout.make(rowWidth: proxy.size.width)
|
||||
let height = proxy.size.height
|
||||
ZStack(alignment: .topLeading) {
|
||||
keyRowSlot(row.leadingSide, height: height) { keyRowSideKey(leading: true) }
|
||||
keyRowSlot(row.leadingContext, height: height) { keyRowContextKey(leading: true) }
|
||||
keyRowSlot(row.send, height: height) { keyRowSendKey }
|
||||
keyRowSlot(row.trailingContext, height: height) { keyRowContextKey(leading: false) }
|
||||
keyRowSlot(row.trailingSide, height: height) { keyRowSideKey(leading: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func keyRowSlot<Content: View>(
|
||||
_ slot: VoiceKeyRowLayout.Slot,
|
||||
height: CGFloat,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
content()
|
||||
.frame(width: slot.width, height: height)
|
||||
.position(x: slot.midX, y: height / 2)
|
||||
}
|
||||
|
||||
/// Keys stay in place while dictation / AI runs; they grey out instead of
|
||||
/// hiding so the row never jumps and a held mic cannot hit them.
|
||||
private var keyRowKeysEnabled: Bool {
|
||||
assistantIsResting
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyRowSideKey(leading: Bool) -> some View {
|
||||
if placesSpaceAndEdit(leading: leading) {
|
||||
RectangularToolbarButton(
|
||||
systemName: "space",
|
||||
label: ExtL10n.string("keyboard.assistant.space"),
|
||||
disabled: !keyRowKeysEnabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
action: state.insertSpace
|
||||
)
|
||||
.accessibilityIdentifier("assistant.space")
|
||||
} else {
|
||||
RepeatingDeleteButton(
|
||||
disabled: !keyRowKeysEnabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
action: state.deleteBackward
|
||||
)
|
||||
.accessibilityIdentifier("assistant.delete")
|
||||
.accessibilityLabel(Text(ExtL10n.string("keyboard.assistant.delete")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo / edit are always shown here and grey out while unavailable.
|
||||
@ViewBuilder
|
||||
private func keyRowContextKey(leading: Bool) -> some View {
|
||||
if placesSpaceAndEdit(leading: leading) {
|
||||
RectangularToolbarButton(
|
||||
systemName: "pencil.line",
|
||||
label: ExtL10n.string("keyboard.edit.apply"),
|
||||
disabled: !state.editAvailable || !keyRowKeysEnabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
onPressingChanged: state.setMicTouchActive,
|
||||
action: state.beginEditLastInput
|
||||
)
|
||||
.accessibilityIdentifier("assistant.edit")
|
||||
.accessibilityHidden(!state.editAvailable)
|
||||
} else {
|
||||
RectangularToolbarButton(
|
||||
systemName: "arrow.uturn.backward",
|
||||
label: ExtL10n.string("keyboard.undoA11y"),
|
||||
disabled: !state.undoAvailable || !keyRowKeysEnabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
action: state.undoLastInsertion
|
||||
)
|
||||
.accessibilityIdentifier("assistant.undo")
|
||||
.accessibilityHidden(!state.undoAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Same icon, enablement and check-mark confirmation as `fieldActionButton`.
|
||||
private var keyRowSendKey: some View {
|
||||
Button(action: performFieldAction) {
|
||||
Image(systemName: fieldActionSystemImage)
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(fieldActionButtonForeground)
|
||||
}
|
||||
.buttonStyle(
|
||||
NativeKeyboardKeyStyle(
|
||||
fill: fieldActionButtonFill,
|
||||
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
|
||||
border: palette.divider,
|
||||
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
|
||||
)
|
||||
)
|
||||
.disabled(!state.assistantActionAvailable || activePipeline)
|
||||
.opacity(activePipeline ? 0.38 : 1)
|
||||
.accessibilityIdentifier(
|
||||
"assistant.action.\(state.returnKeyRole.assistantActionIdentifier)"
|
||||
)
|
||||
.accessibilityLabel(ExtL10n.text(state.returnKeyRole.titleKey))
|
||||
}
|
||||
|
||||
private var lowerCircleSize: CGFloat {
|
||||
compactIPadLayout ? Layout.compactIPadCircleSize : Layout.circleSize
|
||||
}
|
||||
|
||||
private var compactIPadLayout: Bool {
|
||||
state.usesIPadLayoutMetrics && state.layoutWidth > 0 && state.layoutWidth < 420
|
||||
KeyboardChromeLayout.usesCompactIPadVoiceMetrics(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
)
|
||||
}
|
||||
|
||||
private func circleActionButton(
|
||||
@@ -1466,7 +1566,7 @@ struct AIKeyboardView: View {
|
||||
|
||||
private func resetCarousel() {
|
||||
reloadHintPool(resetBag: true)
|
||||
guard !showsClipboardSkills, semanticBadgeContent == nil else { return }
|
||||
guard semanticBadgeContent == nil else { return }
|
||||
showNextHint(animated: false)
|
||||
}
|
||||
|
||||
@@ -1500,6 +1600,35 @@ struct AIKeyboardView: View {
|
||||
/// Uses a subview's intrinsic width until it reaches a visual cap. Unlike
|
||||
/// `frame(maxWidth:)`, this layout does not expand short labels to the cap,
|
||||
/// so each capsule and its complete hit target follow the actual content.
|
||||
/// Reads `state.level` in its own body so the 200 ms mic level tick re-renders
|
||||
/// only these bars instead of the whole assistant surface.
|
||||
private struct AssistantWaveform: View {
|
||||
let state: KeyboardState
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
WaveformView(
|
||||
level: state.level,
|
||||
barCount: 7,
|
||||
color: color,
|
||||
active: true
|
||||
)
|
||||
.frame(width: 38, height: 26)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `state.lastTranscript` in its own body so per-word ASR partials
|
||||
/// re-render only the status line.
|
||||
private struct LiveTranscriptReader<Content: View>: View {
|
||||
let state: KeyboardState
|
||||
@ViewBuilder let content: (String) -> Content
|
||||
|
||||
var body: some View {
|
||||
content(state.lastTranscript)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IntrinsicWidthCap: Layout {
|
||||
let maxWidth: CGFloat
|
||||
|
||||
|
||||
@@ -243,9 +243,7 @@ struct ClipboardHistoryPanelView: View {
|
||||
}
|
||||
|
||||
if history.entries.isEmpty {
|
||||
ExtL10n.text("keyboard.clipboard.panel.empty")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
emptyState
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
@@ -277,6 +275,26 @@ struct ClipboardHistoryPanelView: View {
|
||||
.animation(.easeOut(duration: 0.16), value: showClearConfirmation)
|
||||
}
|
||||
|
||||
/// Icon + title + one-line hint, so an empty panel explains what fills it.
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.font(.system(size: 22, weight: .regular))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.padding(.bottom, 4)
|
||||
.accessibilityHidden(true)
|
||||
ExtL10n.text("keyboard.clipboard.panel.empty")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
ExtL10n.text("keyboard.clipboard.panel.emptyHint")
|
||||
.font(TypeStyle.caption.weight(.regular))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var clearConfirmationOverlay: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "trash")
|
||||
|
||||
@@ -12,7 +12,7 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GlobeInputModeButton: UIViewRepresentable {
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@Binding var isPressed: Bool
|
||||
|
||||
func makeUIView(context: Context) -> GlobeInputModeButtonView {
|
||||
@@ -41,7 +41,7 @@ struct GlobeInputModeButton: UIViewRepresentable {
|
||||
struct SystemGlobeKey: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@State private var isPressed = false
|
||||
|
||||
var width: CGFloat = 44
|
||||
|
||||
@@ -112,7 +112,7 @@ struct KeyboardTopControls: View {
|
||||
@Environment(\.keyboardTabSelectionNamespace) private var sharedSelectionNamespace
|
||||
@Namespace private var fallbackSelectionNamespace
|
||||
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
let palette: ThemePalette
|
||||
|
||||
@@ -8,11 +8,30 @@ struct LastInputEditView: View {
|
||||
private enum Layout {
|
||||
static let primaryButtonHeight = KeyboardChromeLayout.assistantActionCapsuleHeight
|
||||
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
|
||||
static let pagesHeight: CGFloat = 174
|
||||
}
|
||||
|
||||
/// Phone and iPad keep the fixed 281 pt column; the shorter expanded-phone
|
||||
/// and landscape-phone shells take the difference out of the review pages.
|
||||
private var surfaceHeight: CGFloat {
|
||||
guard state.usesExpandedPhoneLayoutMetrics || state.usesCompactHeightPhoneLayoutMetrics else {
|
||||
return KeyboardChromeLayout.totalHeight
|
||||
}
|
||||
return TypingSurfaceMetrics.contentHeight(
|
||||
isIPad: false,
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
}
|
||||
|
||||
private var pagesHeight: CGFloat {
|
||||
Layout.pagesHeight - (KeyboardChromeLayout.totalHeight - surfaceHeight)
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@ObservedObject var state: KeyboardState
|
||||
var state: KeyboardState
|
||||
@State private var selectedPage: Int? = 0
|
||||
|
||||
private var palette: ThemePalette {
|
||||
@@ -32,17 +51,17 @@ struct LastInputEditView: View {
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(height: 174)
|
||||
.frame(height: pagesHeight)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(reviewSwipeGesture)
|
||||
primaryRow.frame(height: 55)
|
||||
}
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: state.usesVoiceKeyRow))
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: KeyboardChromeLayout.totalHeight)
|
||||
.frame(height: surfaceHeight)
|
||||
.environment(\.themePalette, palette)
|
||||
.onChange(of: state.editSession) { _, newValue in
|
||||
guard let review = newValue.review else {
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"keyboard.clipboard.guide.cta" = "Open Settings";
|
||||
"keyboard.clipboard.panel.title" = "Clipboard";
|
||||
"keyboard.clipboard.panel.empty" = "No history yet";
|
||||
"keyboard.clipboard.panel.emptyHint" = "Text you copy shows up here.";
|
||||
"keyboard.clipboard.panel.fullAccessHint" = "New copies are paused because Allow Full Access is off. Existing history remains available.";
|
||||
"keyboard.clipboard.panel.delete" = "Delete";
|
||||
"keyboard.clipboard.panel.close" = "Close clipboard";
|
||||
@@ -370,6 +371,8 @@
|
||||
"keyboard.ai.skill.createContact" = "New contact";
|
||||
"keyboard.ai.skill.extractConclusions" = "Conclusions";
|
||||
"keyboard.ai.skill.translate" = "Translate";
|
||||
"keyboard.ai.skill.translateToTarget" = "Translate";
|
||||
"keyboard.ai.skill.polishOriginal" = "Polish";
|
||||
"keyboard.ai.skill.acceptInvitation" = "Accept";
|
||||
"keyboard.ai.skill.declineInvitation" = "Decline";
|
||||
"keyboard.ai.skill.acceptTask" = "Acknowledge";
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"keyboard.clipboard.guide.cta" = "去设置开启";
|
||||
"keyboard.clipboard.panel.title" = "剪贴板";
|
||||
"keyboard.clipboard.panel.empty" = "暂无历史记录";
|
||||
"keyboard.clipboard.panel.emptyHint" = "复制的文字会出现在这里。";
|
||||
"keyboard.clipboard.panel.fullAccessHint" = "「允许完全访问」已关闭,新复制内容将暂停采集,已有历史仍可使用。";
|
||||
"keyboard.clipboard.panel.delete" = "删除";
|
||||
"keyboard.clipboard.panel.close" = "关闭剪贴板";
|
||||
@@ -370,6 +371,8 @@
|
||||
"keyboard.ai.skill.createContact" = "新建联系人";
|
||||
"keyboard.ai.skill.extractConclusions" = "提取结论";
|
||||
"keyboard.ai.skill.translate" = "翻译";
|
||||
"keyboard.ai.skill.translateToTarget" = "翻译";
|
||||
"keyboard.ai.skill.polishOriginal" = "优化";
|
||||
"keyboard.ai.skill.acceptInvitation" = "接受邀约";
|
||||
"keyboard.ai.skill.declineInvitation" = "委婉拒绝";
|
||||
"keyboard.ai.skill.acceptTask" = "确认任务";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Verifies that the transient suggestion belongs to one pasteboard generation
|
||||
// and one keyboard presentation; clipboard history remains independently usable.
|
||||
|
||||
import Combine
|
||||
import Observation
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@@ -136,17 +136,19 @@ final class ClipboardSuggestionLifecycleTests: XCTestCase {
|
||||
func testUnchangedPollingDoesNotRepublishSuggestion() {
|
||||
copy("stable clipboard")
|
||||
coordinator.captureIfNeeded()
|
||||
var publications = 0
|
||||
let cancellable = state.$clipboardSuggestionText
|
||||
.dropFirst()
|
||||
.sink { _ in publications += 1 }
|
||||
defer { cancellable.cancel() }
|
||||
let republished = expectation(description: "suggestion republished")
|
||||
republished.isInverted = true
|
||||
withObservationTracking {
|
||||
_ = state.clipboardSuggestionText
|
||||
} onChange: {
|
||||
republished.fulfill()
|
||||
}
|
||||
|
||||
coordinator.captureIfNeeded()
|
||||
coordinator.captureIfNeeded()
|
||||
coordinator.captureIfNeeded()
|
||||
|
||||
XCTAssertEqual(publications, 0)
|
||||
wait(for: [republished], timeout: 0.1)
|
||||
XCTAssertEqual(pasteboard.stringReadCount, 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// DictationProgressTests.swift
|
||||
// OSGKeyboard · Keyboard Extension Tests
|
||||
//
|
||||
// Staged microphone progress fill: monotonic, never completes on its own,
|
||||
// steps forward at the ASR → polish milestone, and learns polish latency.
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class DictationProgressTests: XCTestCase {
|
||||
private let start: TimeInterval = 1_000
|
||||
|
||||
private func timeline(polishingAfter seconds: TimeInterval? = nil,
|
||||
expected: TimeInterval = 3) -> DictationProgressTimeline {
|
||||
var timeline = DictationProgressTimeline(utteranceID: UUID(), transcribingStartedAt: start)
|
||||
if let seconds {
|
||||
timeline.markPolishing(at: start + seconds, characterCount: 40, expectedSeconds: expected)
|
||||
}
|
||||
return timeline
|
||||
}
|
||||
|
||||
func testTranscribingStaysBelowCeiling() {
|
||||
let ceiling = DictationProgressEstimator.transcribingCeiling
|
||||
XCTAssertLessThan(DictationProgressEstimator.fraction(for: timeline(), at: start + 3), ceiling)
|
||||
// Long waits converge onto the ceiling (exp underflows to exactly it), never past it.
|
||||
XCTAssertLessThanOrEqual(DictationProgressEstimator.fraction(for: timeline(), at: start + 60), ceiling)
|
||||
XCTAssertEqual(DictationProgressEstimator.fraction(for: timeline(), at: start), 0)
|
||||
}
|
||||
|
||||
func testFractionIsMonotonicAcrossMilestone() {
|
||||
let subject = timeline(polishingAfter: 0.8)
|
||||
var previous = -1.0
|
||||
for step in 0...600 {
|
||||
let value = DictationProgressEstimator.fraction(for: subject, at: start + Double(step) * 0.05)
|
||||
XCTAssertGreaterThanOrEqual(value, previous)
|
||||
previous = value
|
||||
}
|
||||
}
|
||||
|
||||
func testPolishingStepsPastFloorAndNeverCompletes() {
|
||||
let subject = timeline(polishingAfter: 0.8)
|
||||
let afterBlend = DictationProgressEstimator.fraction(for: subject, at: start + 0.8 + 0.3)
|
||||
XCTAssertGreaterThanOrEqual(afterBlend, DictationProgressEstimator.polishingFloor)
|
||||
let muchLater = DictationProgressEstimator.fraction(for: subject, at: start + 600)
|
||||
XCTAssertLessThan(muchLater, 1)
|
||||
XCTAssertLessThanOrEqual(muchLater, DictationProgressEstimator.polishingCeiling)
|
||||
}
|
||||
|
||||
func testRepeatedRawReadyKeepsFirstMilestone() {
|
||||
var subject = timeline(polishingAfter: 1)
|
||||
subject.markPolishing(at: start + 5, characterCount: 999, expectedSeconds: 30)
|
||||
XCTAssertEqual(subject.polishingStartedAt, start + 1)
|
||||
XCTAssertEqual(subject.polishCharacterCount, 40)
|
||||
}
|
||||
|
||||
func testCalibrationMovesTowardObservedLatencyWithinBounds() throws {
|
||||
let suite = "DictationProgressTests.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let calibration = DictationPolishLatencyCalibration(defaults: defaults)
|
||||
|
||||
let initial = calibration.expectedSeconds(forCharacterCount: 100)
|
||||
calibration.record(observedSeconds: 8, characterCount: 100)
|
||||
XCTAssertGreaterThan(calibration.expectedSeconds(forCharacterCount: 100), initial)
|
||||
|
||||
for _ in 0..<50 { calibration.record(observedSeconds: 0.01, characterCount: 0) }
|
||||
XCTAssertEqual(
|
||||
calibration.overheadSeconds,
|
||||
DictationPolishLatencyCalibration.overheadRange.lowerBound,
|
||||
accuracy: 0.001
|
||||
)
|
||||
calibration.record(observedSeconds: -1, characterCount: 0)
|
||||
XCTAssertEqual(
|
||||
calibration.overheadSeconds,
|
||||
DictationPolishLatencyCalibration.overheadRange.lowerBound,
|
||||
accuracy: 0.001
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTimelineClearsWhenProcessingEnds() {
|
||||
let state = KeyboardState()
|
||||
state.phase = .processing
|
||||
state.dictationProgress = timeline()
|
||||
state.phase = .processing
|
||||
XCTAssertNotNil(state.dictationProgress)
|
||||
state.phase = .idle
|
||||
XCTAssertNil(state.dictationProgress)
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// *don't* attempt that here — the State class is what we care about
|
||||
// for correctness.
|
||||
|
||||
import Combine
|
||||
import Observation
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@@ -189,19 +189,17 @@ final class KeyboardStateTests: XCTestCase {
|
||||
)
|
||||
state.enabledClipboardSkills = [first]
|
||||
let published = expectation(description: "resolved skill snapshot published")
|
||||
var cancellable: AnyCancellable?
|
||||
cancellable = state.$enabledClipboardSkills
|
||||
.dropFirst()
|
||||
.sink { skills in
|
||||
XCTAssertEqual(skills.first?.customPrompt, "Second prompt")
|
||||
published.fulfill()
|
||||
}
|
||||
withObservationTracking {
|
||||
_ = state.enabledClipboardSkills
|
||||
} onChange: {
|
||||
published.fulfill()
|
||||
}
|
||||
|
||||
state.enabledClipboardSkills = [second]
|
||||
|
||||
wait(for: [published], timeout: 1)
|
||||
XCTAssertEqual(state.enabledClipboardSkills.first?.customPrompt, "Second prompt")
|
||||
XCTAssertEqual(state.enabledClipboardSkillIDs, ["official.rewrite"])
|
||||
withExtendedLifetime(cancellable) {}
|
||||
}
|
||||
|
||||
func testReturnKeyRoleActionFillAndTitles() {
|
||||
|
||||
@@ -137,15 +137,78 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
||||
XCTAssertEqual(phoneWidths.side, 73.227, accuracy: 0.001)
|
||||
XCTAssertEqual(phoneWidths.center, 203.409, accuracy: 0.001)
|
||||
XCTAssertEqual(phoneWidths.side2, 81.364, accuracy: 0.001)
|
||||
}
|
||||
|
||||
let iPadWidths = KeyboardChromeLayout.iPadVoiceActionKeyWidths(
|
||||
availableWidth: 1024
|
||||
// MARK: - Wide-host voice key row
|
||||
|
||||
func testVoiceKeyRowOnlyOnWideHosts() {
|
||||
// Unfolded iPhone Duo, full width and in Split View.
|
||||
XCTAssertTrue(keyRowAvailable(expandedPhone: true, width: 801))
|
||||
XCTAssertTrue(keyRowAvailable(expandedPhone: true, width: 655))
|
||||
XCTAssertTrue(keyRowAvailable(iPad: true, width: 834))
|
||||
// A phone outside the expanded / landscape tiers keeps the circle
|
||||
// cluster, however wide.
|
||||
XCTAssertFalse(keyRowAvailable(width: 932))
|
||||
// Too narrow for comfortable icon keys, and the pre-layout zero width.
|
||||
XCTAssertFalse(keyRowAvailable(iPad: true, width: 400))
|
||||
XCTAssertFalse(keyRowAvailable(iPad: true, width: 0))
|
||||
XCTAssertFalse(keyRowAvailable(compactHeightPhone: true, width: 0))
|
||||
}
|
||||
|
||||
private func keyRowAvailable(
|
||||
iPad: Bool = false,
|
||||
expandedPhone: Bool = false,
|
||||
compactHeightPhone: Bool = false,
|
||||
width: CGFloat
|
||||
) -> Bool {
|
||||
VoiceKeyRowLayout.isAvailable(
|
||||
usesIPadMetrics: iPad,
|
||||
usesExpandedPhoneMetrics: expandedPhone,
|
||||
usesCompactHeightPhoneMetrics: compactHeightPhone,
|
||||
hostWidth: width
|
||||
)
|
||||
// 1024 − 3 × 8 = 1000pt: keep the globe compact and give return 40%.
|
||||
XCTAssertEqual(iPadWidths.globe, 100, accuracy: 0.001)
|
||||
XCTAssertEqual(iPadWidths.side, 240, accuracy: 0.001)
|
||||
XCTAssertEqual(iPadWidths.center, 400, accuracy: 0.001)
|
||||
XCTAssertEqual(iPadWidths.side2, 260, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testCompactVoiceMetricsIgnoreUnmeasuredWidth() {
|
||||
// 0 = the controller has not measured yet; never treat it as compact.
|
||||
XCTAssertFalse(KeyboardChromeLayout.usesCompactVoiceMetrics(width: 0))
|
||||
// Display Zoom on a small iPhone, or iPad Slide Over.
|
||||
XCTAssertTrue(KeyboardChromeLayout.usesCompactVoiceMetrics(width: 320))
|
||||
XCTAssertFalse(KeyboardChromeLayout.usesCompactVoiceMetrics(width: 375))
|
||||
|
||||
XCTAssertFalse(KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: true, width: 0))
|
||||
XCTAssertTrue(KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: true, width: 400))
|
||||
XCTAssertFalse(KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: false, width: 400))
|
||||
XCTAssertFalse(KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: true, width: 834))
|
||||
}
|
||||
|
||||
func testVoiceContentSpansTypingWidthOnlyWithKeyRow() {
|
||||
XCTAssertEqual(KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: false), 700)
|
||||
XCTAssertEqual(KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: true), .infinity)
|
||||
}
|
||||
|
||||
func testVoiceKeyRowFillsRowWithEqualIconKeysAndDoubleSend() {
|
||||
let spacing = KeyboardChromeLayout.actionKeySpacing
|
||||
// Duo inner display (801 − 2 × 8 inset), iPad portrait, iPad landscape.
|
||||
for rowWidth: CGFloat in [785, 818, 1350] {
|
||||
let row = VoiceKeyRowLayout.make(rowWidth: rowWidth)
|
||||
|
||||
let keyWidth = row.leadingSide.width
|
||||
for slot in [row.leadingContext, row.trailingContext, row.trailingSide] {
|
||||
XCTAssertEqual(slot.width, keyWidth, accuracy: 0.001)
|
||||
}
|
||||
XCTAssertEqual(row.send.width, keyWidth * VoiceKeyRowLayout.sendWidthMultiplier, accuracy: 0.001)
|
||||
|
||||
// Edge to edge with uniform 8 pt gaps — no hole.
|
||||
let slots = [row.leadingSide, row.leadingContext, row.send, row.trailingContext, row.trailingSide]
|
||||
for (left, right) in zip(slots, slots.dropFirst()) {
|
||||
XCTAssertEqual(right.minX - left.maxX, spacing, accuracy: 0.001)
|
||||
}
|
||||
XCTAssertEqual(row.leadingSide.minX, 0)
|
||||
XCTAssertEqual(row.trailingSide.maxX, rowWidth, accuracy: 0.001)
|
||||
// Symmetric row, so send sits under the microphone.
|
||||
XCTAssertEqual(row.send.midX, rowWidth / 2, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
func testIPhoneTypingBottomRowOmitsCustomGlobeAndFillsWidth() {
|
||||
@@ -238,6 +301,174 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testExpandedPhoneMetricsRequirePhoneWithRegularSpaceOnBothAxes() {
|
||||
XCTAssertTrue(
|
||||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||||
isPad: false,
|
||||
hasRegularWidth: true,
|
||||
hasRegularHeight: true
|
||||
),
|
||||
"The unfolded iPhone Duo inner display is regular × regular"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||||
isPad: false,
|
||||
hasRegularWidth: false,
|
||||
hasRegularHeight: true
|
||||
),
|
||||
"Portrait iPhones and the folded Duo stay on phone metrics"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||||
isPad: false,
|
||||
hasRegularWidth: true,
|
||||
hasRegularHeight: false
|
||||
),
|
||||
"Landscape Max-size iPhones stay on phone metrics"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||||
isPad: true,
|
||||
hasRegularWidth: true,
|
||||
hasRegularHeight: true
|
||||
),
|
||||
"iPad keeps its own metrics"
|
||||
)
|
||||
}
|
||||
|
||||
func testGlobeKeyFollowsSystemSwitchRequirementOutsideIPad() {
|
||||
XCTAssertTrue(KeyboardChromeLayout.showsInputModeSwitchKey(isPad: true, systemRequiresSwitchKey: false))
|
||||
XCTAssertTrue(KeyboardChromeLayout.showsInputModeSwitchKey(isPad: false, systemRequiresSwitchKey: true))
|
||||
XCTAssertFalse(
|
||||
KeyboardChromeLayout.showsInputModeSwitchKey(isPad: false, systemRequiresSwitchKey: false),
|
||||
"Face ID iPhones already show the system switch below the keyboard"
|
||||
)
|
||||
}
|
||||
|
||||
func testExpandedPhoneHeightStaysCloseToTheSystemKeyboard() {
|
||||
let phone = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 801)
|
||||
let expanded = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 801, isExpandedPhone: true)
|
||||
|
||||
XCTAssertEqual(phone, KeyboardChromeLayout.totalHeight, "Phone height must not move")
|
||||
XCTAssertEqual(expanded, 260, accuracy: 0.001)
|
||||
XCTAssertEqual(
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: true, width: 834, isExpandedPhone: true),
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: true, width: 834),
|
||||
"iPad ignores the expanded-phone flag"
|
||||
)
|
||||
}
|
||||
|
||||
func testCompactHeightPhoneMetricsCoverEveryLandscapeIPhone() {
|
||||
XCTAssertTrue(
|
||||
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(isPad: false, hasCompactHeight: true),
|
||||
"Landscape iPhones, including the folded Duo, are vertically compact"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(isPad: false, hasCompactHeight: false),
|
||||
"Portrait iPhones and the unfolded Duo keep their tiers"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(isPad: true, hasCompactHeight: true),
|
||||
"iPad keeps its own metrics"
|
||||
)
|
||||
}
|
||||
|
||||
func testVoiceMicSitsCentredBelowTopBarOnEveryShell() {
|
||||
// (shell, mic row, bottom row) for phone, unfolded Duo, landscape, iPad narrow / wide.
|
||||
let shells: [(name: String, shell: CGFloat, mic: CGFloat, bottom: CGFloat)] = [
|
||||
("phone", KeyboardChromeLayout.totalHeight, 60, 52),
|
||||
("duo", TypingSurfaceMetrics.contentHeight(isIPad: false, width: 785, isExpandedPhone: true), 60, 41),
|
||||
("landscape", TypingSurfaceMetrics.contentHeight(isIPad: false, width: 874, isCompactHeightPhone: true), 56, 40),
|
||||
("iPadNarrow", TypingSurfaceMetrics.contentHeight(isIPad: true, width: 834), 60, 54),
|
||||
("iPadWide", TypingSurfaceMetrics.contentHeight(isIPad: true, width: 1194), 60, 76)
|
||||
]
|
||||
for item in shells {
|
||||
let free = item.shell - 8 - 44 - item.mic - item.bottom
|
||||
let context = VoiceMicPlacement.contextHeight(
|
||||
contextAndGap: free,
|
||||
bottomRowHeight: item.bottom,
|
||||
minimumContextHeight: 66
|
||||
)
|
||||
let gap = free - context
|
||||
XCTAssertEqual(context, gap + item.bottom, accuracy: 0.001, "\(item.name): mic centred below the top bar")
|
||||
XCTAssertGreaterThanOrEqual(context, 66, "\(item.name): live transcript must fit")
|
||||
XCTAssertGreaterThanOrEqual(gap, VoiceMicPlacement.minimumGap, item.name)
|
||||
}
|
||||
}
|
||||
|
||||
func testVoiceMicPlacementKeepsTranscriptFloorAndGapCeiling() {
|
||||
// Too short to centre: the transcript floor wins over centring.
|
||||
XCTAssertEqual(
|
||||
VoiceMicPlacement.contextHeight(contextAndGap: 80, bottomRowHeight: 20, minimumContextHeight: 66),
|
||||
66
|
||||
)
|
||||
// Even shorter: the mic still keeps its minimum gap to the bottom row.
|
||||
XCTAssertEqual(
|
||||
VoiceMicPlacement.contextHeight(contextAndGap: 70, bottomRowHeight: 20, minimumContextHeight: 66),
|
||||
70 - VoiceMicPlacement.minimumGap
|
||||
)
|
||||
}
|
||||
|
||||
func testCompactHeightPhoneShortensPanelButKeepsContextSlot() {
|
||||
let portrait = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 874)
|
||||
let landscape = TypingSurfaceMetrics.contentHeight(isIPad: false, width: 874, isCompactHeightPhone: true)
|
||||
XCTAssertEqual(portrait, KeyboardChromeLayout.totalHeight, "Portrait phone height must not move")
|
||||
XCTAssertEqual(landscape, 244, accuracy: 0.001)
|
||||
|
||||
// Voice surface: 4 + 4 padding, 44 top bar, 56 mic row, key row; the
|
||||
// rest splits into the context slot and the gap below the mic.
|
||||
let keyRow = TypingSurfaceMetrics.metrics(isIPad: false, width: 874, isCompactHeightPhone: true).bottomRowHeight
|
||||
let context = VoiceMicPlacement.contextHeight(
|
||||
contextAndGap: landscape - 8 - 44 - 56 - keyRow,
|
||||
bottomRowHeight: keyRow,
|
||||
minimumContextHeight: 66
|
||||
)
|
||||
XCTAssertEqual(context, 68, accuracy: 0.001, "Context slot must fit the live transcript")
|
||||
|
||||
XCTAssertEqual(
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: true, width: 1194, isCompactHeightPhone: true),
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: true, width: 1194),
|
||||
"iPad ignores the compact-height-phone flag"
|
||||
)
|
||||
// Landscape iPhones are all ≥ 667 pt wide, so the voice key row replaces the circles.
|
||||
XCTAssertTrue(VoiceKeyRowLayout.isAvailable(
|
||||
usesIPadMetrics: false,
|
||||
usesExpandedPhoneMetrics: false,
|
||||
usesCompactHeightPhoneMetrics: true,
|
||||
hostWidth: 667
|
||||
))
|
||||
}
|
||||
|
||||
func testExpandedPhoneBottomRowWithGlobeFillsWidth() {
|
||||
let width: CGFloat = 785
|
||||
let metrics = TypingSurfaceMetrics.metrics(isIPad: false, width: width, isExpandedPhone: true)
|
||||
let layout = TypingKeyLayoutBuilder.build(
|
||||
size: CGSize(width: width, height: 260),
|
||||
letterRows: [
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
||||
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
|
||||
["z", "x"]
|
||||
],
|
||||
pageSwitchLabel: "123",
|
||||
spaceLabel: "space",
|
||||
returnLabel: "return",
|
||||
metrics: metrics,
|
||||
includeGlobeKey: true,
|
||||
keyWeight: { _, _, _ in 1 }
|
||||
)
|
||||
|
||||
let slots = [
|
||||
TypingKeyLayoutBuilder.BottomKeyID.globe.rawValue,
|
||||
TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue,
|
||||
TypingKeyLayoutBuilder.BottomKeyID.space.rawValue,
|
||||
TypingKeyLayoutBuilder.BottomKeyID.return.rawValue
|
||||
]
|
||||
let frames = slots.compactMap { layout.key(id: $0)?.visualFrame }
|
||||
XCTAssertEqual(frames.count, 4)
|
||||
XCTAssertEqual(frames.last?.maxX ?? 0, width, accuracy: 0.001)
|
||||
XCTAssertEqual(frames.first?.height ?? 0, TypingSurfaceMetrics.expandedPhoneKeyRowHeight)
|
||||
}
|
||||
|
||||
func testSecondRowInsetKeepsKeysAsWideAsTheFirstRow() {
|
||||
// 10 keys on row 0, 9 on row 1, all weight 1 — the classic QWERTY/ASDF
|
||||
// relationship. Row 1 should be inset by exactly half a key pitch.
|
||||
|
||||
@@ -13,7 +13,7 @@ public protocol AccountHTTPTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
|
||||
public final class URLSessionAccountHTTPTransport: AccountHTTPTransport, @unchecked Sendable {
|
||||
public final class URLSessionAccountHTTPTransport: AccountHTTPTransport, Sendable {
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = .shared) {
|
||||
@@ -716,6 +716,36 @@ public actor AccountAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Debug-only device → Simulator session handoff. The Simulator cannot produce
|
||||
/// DeviceCheck / App Attest evidence, so it cannot sign in, but refresh and
|
||||
/// gateway grants only need the session itself.
|
||||
extension AccountAPIClient {
|
||||
/// Returns the session and forgets it on this device within one call, so
|
||||
/// this client can never rotate the refresh token the Simulator now owns
|
||||
/// (the server would treat a second rotation as reuse and revoke both).
|
||||
/// Throws while a refresh is in flight: the token may already be spent.
|
||||
public func debugRelinquishSession() async throws -> AccountTokenSession {
|
||||
guard refreshOperation == nil,
|
||||
let session = try await reloadSessionFromVault() else {
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
try await clearSession()
|
||||
guard refreshOperation == nil else {
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
/// Installs a session relinquished by another device, replacing any
|
||||
/// cached one so the next request uses it.
|
||||
public func debugAdoptSession(_ session: AccountTokenSession) async throws {
|
||||
try await replaceSession(with: session)
|
||||
try? await sessionVault.clearRefreshTransaction()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
extension Data {
|
||||
func base64URLEncodedString() -> String {
|
||||
base64EncodedString()
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,11 +11,43 @@ import os
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
/// A primed streaming session is opened before the utterance has an owner.
|
||||
/// Route its callbacks through a mutable relay, then bind the real partial
|
||||
/// consumer when the mic gesture is committed to an utterance.
|
||||
private final class CloudASRPartialRelay: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock<
|
||||
(@Sendable (String) -> Void)?
|
||||
>(initialState: nil)
|
||||
|
||||
func bind(_ consumer: @escaping @Sendable (String) -> Void) {
|
||||
lock.withLock { $0 = consumer }
|
||||
}
|
||||
|
||||
func publish(_ text: String) {
|
||||
let consumer = lock.withLock { $0 }
|
||||
consumer?(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads PCM only on the user-selected cloud engine path; provider clients
|
||||
/// reject missing credentials before network transmission. Personal dictionary
|
||||
/// entries are sent as recognition bias. Mutable client/cancellation state is
|
||||
/// lock-protected, which is the basis for `@unchecked Sendable`.
|
||||
public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
private struct PrimedStreamingSession {
|
||||
let id: UUID
|
||||
let selection: String
|
||||
let localeID: String
|
||||
let createdAt: ContinuousClock.Instant
|
||||
let relay: CloudASRPartialRelay
|
||||
let task: Task<any CloudASRStreamingSession, Error>
|
||||
}
|
||||
|
||||
/// The gateway closes a session that receives no audio for 15 s (counted
|
||||
/// from WebSocket connect). A primed session older than this is never
|
||||
/// adopted, leaving headroom for the first audio frame to arrive.
|
||||
public static let defaultPrimedSessionMaxAge: Duration = .seconds(10)
|
||||
|
||||
private let store: any ConfigurationStore
|
||||
private let session: URLSession
|
||||
private let localFallback: ASRService
|
||||
@@ -23,24 +55,28 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
/// provider-specific local-fallback selection unchanged.
|
||||
private let managedClient: (any CloudASRTranscribing)?
|
||||
private let managedGrants: GatewayGrantCoordinator?
|
||||
private let primedSessionMaxAge: Duration
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var client: CloudASRTranscribing?
|
||||
private var usesLocalFallback = false
|
||||
private var boundClientSelection: String?
|
||||
private var cancelled = false
|
||||
private var streamingPipeline: StreamingUtterancePipeline?
|
||||
private var primedStreamingSession: PrimedStreamingSession?
|
||||
|
||||
public init(
|
||||
store: any ConfigurationStore = AppGroupStore(),
|
||||
session: URLSession = .shared,
|
||||
localFallback: ASRService? = nil,
|
||||
managedClient: (any CloudASRTranscribing)? = nil,
|
||||
managedGrants: GatewayGrantCoordinator? = nil
|
||||
managedGrants: GatewayGrantCoordinator? = nil,
|
||||
primedSessionMaxAge: Duration = CloudASRService.defaultPrimedSessionMaxAge
|
||||
) {
|
||||
self.store = store
|
||||
self.session = session
|
||||
self.managedClient = managedClient
|
||||
self.managedGrants = managedGrants
|
||||
self.primedSessionMaxAge = primedSessionMaxAge
|
||||
// `SpeechAnalyzerASR` is internal, so it can't appear in a public
|
||||
// default argument value — resolve the fallback in the body instead.
|
||||
self.localFallback = localFallback ?? SpeechAnalyzerASR()
|
||||
@@ -77,6 +113,143 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the account-managed Volcengine reservation + WebSocket handshake
|
||||
/// while the user's finger is still down. The utterance adopts this session
|
||||
/// moments later, removing those network round trips from first-partial
|
||||
/// latency without sending audio before recording actually begins.
|
||||
public func primeUtteranceStreaming(locale: Locale) {
|
||||
bindClientIfNeeded()
|
||||
guard store.credentialSource == .managed,
|
||||
let selection = lock.withLock({ boundClientSelection }),
|
||||
let streamingClient = lock.withLock({
|
||||
client as? any CloudASRStreamingCapable
|
||||
}) else {
|
||||
return
|
||||
}
|
||||
|
||||
let localeID = locale.identifier(.bcp47)
|
||||
let alreadyPrimed = lock.withLock { () -> Bool in
|
||||
guard let current = primedStreamingSession else { return false }
|
||||
return current.selection == selection
|
||||
&& current.localeID == localeID
|
||||
&& !isExpired(current)
|
||||
}
|
||||
guard !alreadyPrimed else { return }
|
||||
|
||||
cancelPrimedUtteranceStreaming()
|
||||
let id = UUID()
|
||||
let relay = CloudASRPartialRelay()
|
||||
let dictionary = store.personalDictionary
|
||||
let task = Task.detached(priority: .userInitiated) {
|
||||
try await streamingClient.openStreamingSession(
|
||||
locale: locale,
|
||||
dictionary: dictionary,
|
||||
onPartial: { relay.publish($0) }
|
||||
)
|
||||
}
|
||||
lock.withLock {
|
||||
primedStreamingSession = PrimedStreamingSession(
|
||||
id: id,
|
||||
selection: selection,
|
||||
localeID: localeID,
|
||||
createdAt: .now,
|
||||
relay: relay,
|
||||
task: task
|
||||
)
|
||||
}
|
||||
// Self-expire: the keyboard's cancel command may never arrive (the
|
||||
// extension can be dismissed, or a start can fail before adoption).
|
||||
let maxAge = primedSessionMaxAge
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: maxAge)
|
||||
guard let self, let expired = self.takePrimedSession(id: id) else { return }
|
||||
FlowTrace.asr("cloud.stream.primeDiscarded", "reason=expired")
|
||||
Self.close(expired)
|
||||
}
|
||||
FlowTrace.asr(
|
||||
"cloud.stream.primeStarted",
|
||||
"locale=\(localeID) selection=\(selection)"
|
||||
)
|
||||
}
|
||||
|
||||
public func cancelPrimedUtteranceStreaming() {
|
||||
guard let primed = takePrimedSession() else { return }
|
||||
Self.close(primed)
|
||||
}
|
||||
|
||||
/// Removes the primed session; with an `id`, only if it is still that one.
|
||||
private func takePrimedSession(id: UUID? = nil) -> PrimedStreamingSession? {
|
||||
lock.withLock {
|
||||
guard let current = primedStreamingSession,
|
||||
id == nil || current.id == id else {
|
||||
return nil
|
||||
}
|
||||
primedStreamingSession = nil
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
private func isExpired(_ primed: PrimedStreamingSession) -> Bool {
|
||||
ContinuousClock.now - primed.createdAt >= primedSessionMaxAge
|
||||
}
|
||||
|
||||
/// Closes a primed session without cancelling its open task. Cancelling
|
||||
/// mid-reservation can leave a reserved, never-connected gateway session
|
||||
/// holding credits and a concurrency slot until its connect timeout;
|
||||
/// letting the open finish and then closing releases it immediately.
|
||||
private static func close(_ primed: PrimedStreamingSession) {
|
||||
Task {
|
||||
if let session = try? await primed.task.value {
|
||||
session.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the primed session only if it still matches this utterance and
|
||||
/// can accept audio. Anything else is closed and the caller opens a fresh
|
||||
/// session, so priming can never make an utterance fail.
|
||||
private func adoptPrimedSession(
|
||||
_ primed: PrimedStreamingSession?,
|
||||
selection: String?,
|
||||
localeID: String,
|
||||
onPartial: @escaping @Sendable (String) -> Void
|
||||
) async -> (any CloudASRStreamingSession)? {
|
||||
guard let primed else { return nil }
|
||||
guard primed.selection == selection, primed.localeID == localeID else {
|
||||
FlowTrace.asr("cloud.stream.primeDiscarded", "reason=mismatch")
|
||||
Self.close(primed)
|
||||
return nil
|
||||
}
|
||||
guard !isExpired(primed) else {
|
||||
FlowTrace.asr("cloud.stream.primeDiscarded", "reason=expired")
|
||||
Self.close(primed)
|
||||
return nil
|
||||
}
|
||||
|
||||
primed.relay.bind(onPartial)
|
||||
let session: any CloudASRStreamingSession
|
||||
do {
|
||||
session = try await primed.task.value
|
||||
} catch {
|
||||
FlowTrace.asr(
|
||||
"cloud.stream.primeDiscarded",
|
||||
"reason=openFailed \(CloudASRLogMetadata.describe(error))"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
guard session.acceptsAudio else {
|
||||
FlowTrace.asr("cloud.stream.primeDiscarded", "reason=closed")
|
||||
session.cancel()
|
||||
return nil
|
||||
}
|
||||
let ageMs = Int((ContinuousClock.now - primed.createdAt) / .milliseconds(1))
|
||||
FlowTrace.asr(
|
||||
"cloud.stream.primeAdopted",
|
||||
"locale=\(localeID) ageMs=\(ageMs)"
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
public func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
if Task.isCancelled || lock.withLock({ cancelled }) { return .cancelled }
|
||||
@@ -129,23 +302,42 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
onPartial: @escaping @Sendable (String) -> Void
|
||||
) async -> ChunkedUtterancePipelineOutcome {
|
||||
bindClientIfNeeded()
|
||||
// A previous utterance's abort/failure leaves `cancelled` set, which
|
||||
// would make this utterance's batch fallback (`transcribeChunk`)
|
||||
// return `.cancelled` for the rest of the Flow session.
|
||||
lock.withLock { cancelled = false }
|
||||
if usesLocalFallback {
|
||||
let pipeline = ChunkedUtterancePipeline(asr: localFallback, locale: locale)
|
||||
return await pipeline.transcribe(stream: stream, onPartial: onPartial)
|
||||
}
|
||||
|
||||
guard let streamingClient = lock.withLock({ client as? CloudASRStreamingCapable }) else {
|
||||
cancelPrimedUtteranceStreaming()
|
||||
let pipeline = ChunkedUtterancePipeline(asr: self, locale: locale)
|
||||
return await pipeline.transcribe(stream: stream, onPartial: onPartial)
|
||||
}
|
||||
|
||||
let selection = lock.withLock { boundClientSelection }
|
||||
let localeID = locale.identifier(.bcp47)
|
||||
// Every utterance either adopts the primed session or closes it.
|
||||
let primed = takePrimedSession()
|
||||
|
||||
let session: any CloudASRStreamingSession
|
||||
do {
|
||||
session = try await streamingClient.openStreamingSession(
|
||||
locale: locale,
|
||||
dictionary: store.personalDictionary,
|
||||
if let adopted = await adoptPrimedSession(
|
||||
primed,
|
||||
selection: selection,
|
||||
localeID: localeID,
|
||||
onPartial: onPartial
|
||||
)
|
||||
) {
|
||||
session = adopted
|
||||
} else {
|
||||
session = try await streamingClient.openStreamingSession(
|
||||
locale: locale,
|
||||
dictionary: store.personalDictionary,
|
||||
onPartial: onPartial
|
||||
)
|
||||
}
|
||||
} catch where ProviderToolCancellation.matches(error) {
|
||||
return .cancelled
|
||||
} catch {
|
||||
@@ -249,6 +441,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
|
||||
public func cancel() {
|
||||
lock.withLock { cancelled = true }
|
||||
cancelPrimedUtteranceStreaming()
|
||||
let pipeline = lock.withLock { streamingPipeline }
|
||||
Task { await pipeline?.cancel() }
|
||||
localFallback.cancel()
|
||||
@@ -259,8 +452,10 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: providerId)
|
||||
let credentialSource = store.credentialSource
|
||||
let selection = "\(credentialSource.rawValue):\(providerId)"
|
||||
lock.withLock {
|
||||
guard boundClientSelection != selection else { return }
|
||||
let stalePrime = lock.withLock { () -> PrimedStreamingSession? in
|
||||
guard boundClientSelection != selection else { return nil }
|
||||
let stalePrime = primedStreamingSession
|
||||
primedStreamingSession = nil
|
||||
boundClientSelection = selection
|
||||
if credentialSource == .managed {
|
||||
usesLocalFallback = false
|
||||
@@ -269,12 +464,16 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
session: session,
|
||||
managedGrants: managedGrants
|
||||
)
|
||||
return
|
||||
return stalePrime
|
||||
}
|
||||
usesLocalFallback = strategy == .localFallback
|
||||
client = usesLocalFallback
|
||||
? nil
|
||||
: CloudASRClientFactory.make(store: store, session: session)
|
||||
return stalePrime
|
||||
}
|
||||
if let stalePrime {
|
||||
Self.close(stalePrime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ public protocol CloudASRStreamingSession: Sendable {
|
||||
/// Signal end-of-audio and wait for the polish-ready final transcript.
|
||||
func finish() async throws -> String
|
||||
func cancel()
|
||||
/// False once the session has failed, closed, or been cancelled, so a
|
||||
/// pre-opened session is not adopted after its transport already died.
|
||||
var acceptsAudio: Bool { get }
|
||||
}
|
||||
|
||||
public extension CloudASRStreamingSession {
|
||||
var acceptsAudio: Bool { true }
|
||||
}
|
||||
|
||||
/// Providers that can open an utterance-level streaming session.
|
||||
|
||||
@@ -765,6 +765,12 @@ private final class ManagedVolcengineASRSession: CloudASRStreamingSession, @unch
|
||||
}
|
||||
}
|
||||
|
||||
var acceptsAudio: Bool {
|
||||
lock.withLock { state in
|
||||
!state.cancelled && state.failure == nil && !state.receiveClosed && !state.endSent
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
let changed = lock.withLock { state -> Bool in
|
||||
guard !state.cancelled else { return false }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
@@ -67,8 +68,24 @@ public final class FlowAudioSessionCoordinator: @unchecked Sendable {
|
||||
)
|
||||
private var mode: Mode = .inactive
|
||||
private var active = false
|
||||
private let mediaServicesResetCounter = OSAllocatedUnfairLock(initialState: UInt64(0))
|
||||
|
||||
private init() {}
|
||||
/// Increments every time the system media server finishes a reset, so a
|
||||
/// caller backing off between activation attempts can retry immediately.
|
||||
public var mediaServicesResetCount: UInt64 {
|
||||
mediaServicesResetCounter.withLock { $0 }
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Process-lifetime singleton, so the observer is never removed.
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { [mediaServicesResetCounter] _ in
|
||||
mediaServicesResetCounter.withLock { $0 &+= 1 }
|
||||
}
|
||||
}
|
||||
|
||||
public func activateCapture() async throws -> FlowAudioSessionSnapshot {
|
||||
let activation: CaptureActivation = try await perform {
|
||||
@@ -127,20 +144,22 @@ public final class FlowAudioSessionCoordinator: @unchecked Sendable {
|
||||
// tap are stopped separately, so the microphone is released,
|
||||
// while avoiding a category flip that eventually yields !pri
|
||||
// after repeated PiP/capture cycles.
|
||||
if self.active, self.mode == .capture {
|
||||
// A media server reset drops the category and activation
|
||||
// without telling this cache, so confirm against the live
|
||||
// session. Re-activating an active session is a no-op.
|
||||
if self.active, self.mode == .capture,
|
||||
self.session.category == .playAndRecord {
|
||||
return self.makeSnapshot()
|
||||
}
|
||||
if self.mode != .playback {
|
||||
if self.mode != .playback || self.session.category != .playback {
|
||||
try self.session.setCategory(
|
||||
.playback,
|
||||
mode: .moviePlayback,
|
||||
options: [.mixWithOthers]
|
||||
)
|
||||
}
|
||||
if !self.active {
|
||||
try self.session.setActive(true)
|
||||
self.active = true
|
||||
}
|
||||
try self.session.setActive(true)
|
||||
self.active = true
|
||||
self.mode = .playback
|
||||
return self.makeSnapshot()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// is server-authoritative either way.
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
@@ -46,7 +47,11 @@ struct MacAccountSettingsSection: View {
|
||||
case let .signedIn(session):
|
||||
identityRow(session)
|
||||
creditRows
|
||||
MacInviteLinkRow(profile: coordinator.referralProfile, language: lang)
|
||||
MacInviteLinkRow(
|
||||
profile: coordinator.referralProfile,
|
||||
inviterName: session.displayName,
|
||||
language: lang
|
||||
)
|
||||
referralCountRows
|
||||
accountActionsRow
|
||||
deleteRows
|
||||
@@ -58,9 +63,7 @@ struct MacAccountSettingsSection: View {
|
||||
MacL10n.string("mac.account.error.title", language: lang),
|
||||
isPresented: operationErrorBinding
|
||||
) {
|
||||
Button(MacL10n.string("mac.account.error.dismiss", language: lang)) {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
Button(MacL10n.string("mac.account.error.dismiss", language: lang)) {}
|
||||
} message: {
|
||||
if let key = coordinator.operationErrorKey {
|
||||
// The code is appended verbatim: on Mac there is no TestFlight
|
||||
@@ -268,7 +271,12 @@ struct MacAccountSettingsSection: View {
|
||||
Binding(
|
||||
get: { coordinator.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { coordinator.dismissOperationError() }
|
||||
guard !isPresented else { return }
|
||||
// SwiftUI writes this binding while updating the alert.
|
||||
// Defer ObservableObject mutations until that update ends.
|
||||
Task { @MainActor in
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -283,10 +291,12 @@ private struct MacInviteLinkRow: View {
|
||||
@ObservedObject var profile: ReferralProfileViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let inviterName: String?
|
||||
let language: AppUILanguage
|
||||
|
||||
@State private var didCopy = false
|
||||
@State private var copyResetTask: Task<Void, Never>?
|
||||
@State private var isShowingPoster = false
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
@@ -312,23 +322,46 @@ private struct MacInviteLinkRow: View {
|
||||
title: MacL10n.string("mac.account.invite.link", language: language),
|
||||
subtitle: loaded.code.inviteURL.absoluteString
|
||||
) {
|
||||
Button {
|
||||
copy(loaded.code.inviteURL)
|
||||
} label: {
|
||||
Label(
|
||||
MacL10n.string(
|
||||
didCopy
|
||||
? "mac.account.invite.copied"
|
||||
: "mac.account.invite.copy",
|
||||
HStack(spacing: Spacing.md) {
|
||||
Button {
|
||||
isShowingPoster = true
|
||||
} label: {
|
||||
Label(
|
||||
MacL10n.string("mac.account.invite.qr", language: language),
|
||||
systemImage: "qrcode"
|
||||
)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityIdentifier("mac.account.invite.qr")
|
||||
.popover(isPresented: $isShowingPoster, arrowEdge: .bottom) {
|
||||
MacInvitePosterPopover(
|
||||
inviteURL: loaded.code.inviteURL,
|
||||
shortCode: loaded.code.displayShortCode,
|
||||
inviterName: inviterName,
|
||||
language: language
|
||||
),
|
||||
systemImage: didCopy ? "checkmark" : "doc.on.doc"
|
||||
)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
copy(loaded.code.inviteURL)
|
||||
} label: {
|
||||
Label(
|
||||
MacL10n.string(
|
||||
didCopy
|
||||
? "mac.account.invite.copied"
|
||||
: "mac.account.invite.copy",
|
||||
language: language
|
||||
),
|
||||
systemImage: didCopy ? "checkmark" : "doc.on.doc"
|
||||
)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityIdentifier("mac.account.invite.copy")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityIdentifier("mac.account.invite.copy")
|
||||
}
|
||||
|
||||
case let .failed(messageKey):
|
||||
@@ -361,3 +394,95 @@ private struct MacInviteLinkRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same poster as iOS. On a Mac it is mostly scanned straight off the
|
||||
/// screen with a phone, or saved as a PNG to send from a chat app.
|
||||
private struct MacInvitePosterPopover: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let inviteURL: URL
|
||||
let shortCode: String?
|
||||
let inviterName: String?
|
||||
let language: AppUILanguage
|
||||
|
||||
@State private var poster: CGImage?
|
||||
@State private var saveFailed = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Group {
|
||||
if let poster {
|
||||
Image(decorative: poster, scale: InvitePosterRenderer.exportScale)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.frame(width: 270, height: 360)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
|
||||
// Selectable so it can be copied into a chat on the Mac.
|
||||
if let shortCode {
|
||||
Text(SharedL10n.format("invite.poster.code", language: language, shortCode))
|
||||
.font(.system(.body, design: .monospaced, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
.accessibilityIdentifier("mac.account.invite.shortCode")
|
||||
}
|
||||
|
||||
Text(MacL10n.string("mac.account.invite.qrHint", language: language))
|
||||
.font(MacSettingsType.hint)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button(MacL10n.string("mac.account.invite.savePoster", language: language)) {
|
||||
savePoster()
|
||||
}
|
||||
.disabled(poster == nil)
|
||||
.accessibilityIdentifier("mac.account.invite.savePoster")
|
||||
|
||||
if saveFailed {
|
||||
Text(MacL10n.string("mac.account.invite.saveFailed", language: language))
|
||||
.font(MacSettingsType.hint)
|
||||
.foregroundStyle(palette.danger)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.frame(width: 310)
|
||||
.task(id: colorScheme) {
|
||||
poster = InvitePosterRenderer.render(
|
||||
inviteURL: inviteURL,
|
||||
shortCode: shortCode,
|
||||
inviterName: inviterName,
|
||||
language: language,
|
||||
colorScheme: colorScheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func savePoster() {
|
||||
#if os(macOS)
|
||||
guard let poster else { return }
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.png]
|
||||
panel.canCreateDirectories = true
|
||||
panel.nameFieldStringValue = "OSGKeyboard-Invite.png"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
|
||||
let bitmap = NSBitmapImageRep(cgImage: poster)
|
||||
guard let data = bitmap.representation(using: .png, properties: [:]) else {
|
||||
saveFailed = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
try data.write(to: url, options: .atomic)
|
||||
saveFailed = false
|
||||
} catch {
|
||||
saveFailed = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ struct MacRootView: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -97,6 +97,7 @@ public struct LiveConfigurationSnapshot: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// Build from live `ProviderConfig` plus persisted App Group extras.
|
||||
@MainActor
|
||||
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
||||
self.init(
|
||||
providerId: config.providerId,
|
||||
@@ -136,6 +137,7 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
|
||||
self.init(snapshot: LiveConfigurationSnapshot(store: store))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public init(config: ProviderConfig, fallback: AppGroupStore) {
|
||||
self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ public enum OSGColor {
|
||||
public static let fixedLightContent = Color.white
|
||||
public static let fixedDarkContent = Color.black
|
||||
|
||||
// MARK: - Invitation poster
|
||||
|
||||
/// Shared artwork must look identical for every recipient, so these do not
|
||||
/// follow the app appearance. The background matches the app icon.
|
||||
public static let invitePosterBackground = color(0x252527)
|
||||
public static let invitePosterHint = color(0xA1A1A6)
|
||||
|
||||
// MARK: - iOS app theme
|
||||
|
||||
public static let darkBackground = color(0x0A0A0B)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, @unchecked Sendable {
|
||||
public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, Sendable {
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = .shared) {
|
||||
|
||||
@@ -13,7 +13,7 @@ public protocol GatewayGrantCredentialStore: Sendable {
|
||||
func delete() async throws
|
||||
}
|
||||
|
||||
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
|
||||
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, Sendable {
|
||||
public enum Slot: String, Sendable {
|
||||
case account = "scope-limited.active"
|
||||
case oobe = "scope-limited.oobe"
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// DictationProgress.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Staged, time-estimated progress for the dictation wait after the user stops
|
||||
// recording. The host only reports two milestones to the keyboard (`rawReady`
|
||||
// when ASR finishes, `final` when polish finishes) and polish is a single
|
||||
// non-streaming request, so the fill between milestones is an estimate: it
|
||||
// eases toward a ceiling it never reaches, and only the real `final` result
|
||||
// completes the bar.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Timestamps of one dictation wait, owned by `KeyboardState`.
|
||||
public struct DictationProgressTimeline: Equatable, Sendable {
|
||||
public let utteranceID: UUID
|
||||
public let transcribingStartedAt: TimeInterval
|
||||
public private(set) var polishingStartedAt: TimeInterval?
|
||||
/// Raw transcript length when polish began; scales the expected duration.
|
||||
public private(set) var polishCharacterCount: Int = 0
|
||||
public private(set) var expectedPolishSeconds: TimeInterval = 0
|
||||
|
||||
public init(utteranceID: UUID, transcribingStartedAt: TimeInterval) {
|
||||
self.utteranceID = utteranceID
|
||||
self.transcribingStartedAt = transcribingStartedAt
|
||||
}
|
||||
|
||||
/// Records the ASR → polish milestone once; later `rawReady` repeats are ignored.
|
||||
public mutating func markPolishing(
|
||||
at time: TimeInterval,
|
||||
characterCount: Int,
|
||||
expectedSeconds: TimeInterval
|
||||
) {
|
||||
guard polishingStartedAt == nil else { return }
|
||||
polishingStartedAt = time
|
||||
polishCharacterCount = characterCount
|
||||
expectedPolishSeconds = expectedSeconds
|
||||
}
|
||||
}
|
||||
|
||||
public enum DictationProgressEstimator {
|
||||
/// Asymptote of the ASR stage; the bar never passes it before `rawReady`.
|
||||
static let transcribingCeiling = 0.25
|
||||
/// Time constant of the ASR stage curve (local drain is usually < 1 s).
|
||||
static let transcribingTimeConstant: TimeInterval = 1.0
|
||||
/// Where the polish stage starts, so `rawReady` reads as a visible step.
|
||||
static let polishingFloor = 0.35
|
||||
/// Asymptote of the polish stage; only the real result fills to 100 %.
|
||||
static let polishingCeiling = 0.95
|
||||
/// Duration of the ease from the ASR curve into the polish curve.
|
||||
static let stageBlendSeconds: TimeInterval = 0.25
|
||||
|
||||
/// Fill fraction in `0...polishingCeiling` for `timeline` at `now`.
|
||||
public static func fraction(
|
||||
for timeline: DictationProgressTimeline,
|
||||
at now: TimeInterval
|
||||
) -> Double {
|
||||
guard let polishStart = timeline.polishingStartedAt else {
|
||||
return transcribingFraction(elapsed: now - timeline.transcribingStartedAt)
|
||||
}
|
||||
let atMilestone = transcribingFraction(
|
||||
elapsed: polishStart - timeline.transcribingStartedAt
|
||||
)
|
||||
let elapsed = max(0, now - polishStart)
|
||||
// At `elapsed == expected` the polish curve covers ~86 % of its range.
|
||||
let timeConstant = max(timeline.expectedPolishSeconds, 0.5) / 2
|
||||
let polishing = polishingFloor
|
||||
+ (polishingCeiling - polishingFloor) * (1 - exp(-elapsed / timeConstant))
|
||||
let blend = min(elapsed / stageBlendSeconds, 1)
|
||||
return atMilestone + (polishing - atMilestone) * blend
|
||||
}
|
||||
|
||||
static func transcribingFraction(elapsed: TimeInterval) -> Double {
|
||||
transcribingCeiling * (1 - exp(-max(0, elapsed) / transcribingTimeConstant))
|
||||
}
|
||||
}
|
||||
|
||||
/// Learns this device's polish latency so the estimate tracks the user's
|
||||
/// provider and network. Stored in the extension's own defaults; it is a UI
|
||||
/// hint, not a setting, so it is neither synced nor shared with the host.
|
||||
public struct DictationPolishLatencyCalibration {
|
||||
static let overheadKey = "keyboard.dictationProgress.polishOverheadSeconds"
|
||||
static let defaultOverheadSeconds: TimeInterval = 1.5
|
||||
static let secondsPerCharacter: TimeInterval = 0.012
|
||||
static let overheadRange: ClosedRange<TimeInterval> = 0.5...15
|
||||
/// Weight of the newest observation in the moving average.
|
||||
static let smoothing = 0.3
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public func expectedSeconds(forCharacterCount count: Int) -> TimeInterval {
|
||||
overheadSeconds + Double(max(0, count)) * Self.secondsPerCharacter
|
||||
}
|
||||
|
||||
public func record(observedSeconds: TimeInterval, characterCount: Int) {
|
||||
guard observedSeconds.isFinite, observedSeconds > 0 else { return }
|
||||
let observedOverhead = observedSeconds
|
||||
- Double(max(0, characterCount)) * Self.secondsPerCharacter
|
||||
let blended = overheadSeconds * (1 - Self.smoothing) + observedOverhead * Self.smoothing
|
||||
defaults.set(
|
||||
min(max(blended, Self.overheadRange.lowerBound), Self.overheadRange.upperBound),
|
||||
forKey: Self.overheadKey
|
||||
)
|
||||
}
|
||||
|
||||
var overheadSeconds: TimeInterval {
|
||||
let stored = defaults.double(forKey: Self.overheadKey)
|
||||
return stored > 0 ? stored : Self.defaultOverheadSeconds
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,13 @@ public enum KeyboardChromeLayout {
|
||||
/// keyboard), which is why it no longer shares this constant.
|
||||
public static let voiceContentMaxWidth: CGFloat = 700
|
||||
|
||||
/// Hosts that draw the keyboard-style voice key row span the typing width:
|
||||
/// the row, top bar and logo must sit on the same frame as the typing keys,
|
||||
/// or they jump sideways on every tab switch.
|
||||
public static func voiceContentWidthCap(usesVoiceKeyRow: Bool) -> CGFloat {
|
||||
usesVoiceKeyRow ? .infinity : voiceContentMaxWidth
|
||||
}
|
||||
|
||||
/// Width at or above which the typing surface switches to wide-iPad
|
||||
/// metrics (taller rows). Chosen to sit between the widest iPad portrait
|
||||
/// width (1024 pt on 13") and the narrowest landscape width (1133 pt on
|
||||
@@ -60,11 +67,54 @@ public enum KeyboardChromeLayout {
|
||||
isPad && hasRegularWidth
|
||||
}
|
||||
|
||||
/// System-sized iPhone-shaped keys for a phone host with regular space on
|
||||
/// both axes — the unfolded iPhone Duo inner display. Landscape Max-size
|
||||
/// iPhones are regular × compact and keep the phone metrics.
|
||||
public static func usesExpandedPhoneMetrics(
|
||||
isPad: Bool,
|
||||
hasRegularWidth: Bool,
|
||||
hasRegularHeight: Bool
|
||||
) -> Bool {
|
||||
!isPad && hasRegularWidth && hasRegularHeight
|
||||
}
|
||||
|
||||
/// Shorter phone panel whenever the host is vertically compact: every
|
||||
/// iPhone in landscape, including the folded iPhone Duo. Compact-height
|
||||
/// iPad windows keep their own metrics.
|
||||
public static func usesCompactHeightPhoneMetrics(isPad: Bool, hasCompactHeight: Bool) -> Bool {
|
||||
!isPad && hasCompactHeight
|
||||
}
|
||||
|
||||
/// iPad always draws its own globe key. Elsewhere UIKit decides: Face ID
|
||||
/// iPhones get a system switch below the keyboard, while the unfolded
|
||||
/// iPhone Duo and Home-button iPhones need the keyboard to provide one.
|
||||
public static func showsInputModeSwitchKey(
|
||||
isPad: Bool,
|
||||
systemRequiresSwitchKey: Bool
|
||||
) -> Bool {
|
||||
isPad || systemRequiresSwitchKey
|
||||
}
|
||||
|
||||
/// Wide metrics need iPad-scale keys *and* enough width to justify them.
|
||||
public static func usesWideIPadMetrics(isIPad: Bool, width: CGFloat) -> Bool {
|
||||
isIPad && width >= wideIPadWidthThreshold
|
||||
}
|
||||
|
||||
/// Below this width the voice microphone capsule shrinks (Display Zoom on
|
||||
/// a small iPhone, iPad Slide Over). Every current iPhone is wider.
|
||||
public static let compactVoiceWidthThreshold: CGFloat = 350
|
||||
/// Below this width an iPad-metrics voice surface uses its smaller circles.
|
||||
public static let compactIPadVoiceWidthThreshold: CGFloat = 420
|
||||
|
||||
/// `width == 0` means "not measured yet" and never counts as compact.
|
||||
public static func usesCompactVoiceMetrics(width: CGFloat) -> Bool {
|
||||
width > 0 && width < compactVoiceWidthThreshold
|
||||
}
|
||||
|
||||
public static func usesCompactIPadVoiceMetrics(isIPad: Bool, width: CGFloat) -> Bool {
|
||||
isIPad && width > 0 && width < compactIPadVoiceWidthThreshold
|
||||
}
|
||||
|
||||
/// Splits the width left after three gaps into a 12 / 18 / 50 / 20 row for
|
||||
/// layouts that include a globe key, with a wide centre and balanced sides.
|
||||
public static func actionKeyWidths(availableWidth: CGFloat) -> (globe: CGFloat, side: CGFloat, center: CGFloat, side2: CGFloat) {
|
||||
@@ -93,28 +143,6 @@ public enum KeyboardChromeLayout {
|
||||
)
|
||||
}
|
||||
|
||||
/// iPad voice bottom row `[globe · delete · return · space]`. The phone's
|
||||
/// 12/18/50/20 split would hand the centre key ~577 pt once the surface
|
||||
/// spans an iPad; these fractions keep every key in a usable range while
|
||||
/// giving the primary return action a little more room.
|
||||
public static let iPadVoiceGlobeFraction: CGFloat = 0.10
|
||||
public static let iPadVoiceSideFraction: CGFloat = 0.24
|
||||
public static let iPadVoiceCenterFraction: CGFloat = 0.40
|
||||
public static let iPadVoiceSide2Fraction: CGFloat = 0.26
|
||||
|
||||
/// iPad variant of `actionKeyWidths` for the voice surface.
|
||||
public static func iPadVoiceActionKeyWidths(
|
||||
availableWidth: CGFloat
|
||||
) -> (globe: CGFloat, side: CGFloat, center: CGFloat, side2: CGFloat) {
|
||||
let keyWidth = max(0, availableWidth - actionKeySpacing * 3)
|
||||
return (
|
||||
globe: keyWidth * iPadVoiceGlobeFraction,
|
||||
side: keyWidth * iPadVoiceSideFraction,
|
||||
center: keyWidth * iPadVoiceCenterFraction,
|
||||
side2: keyWidth * iPadVoiceSide2Fraction
|
||||
)
|
||||
}
|
||||
|
||||
/// Six-slot iPad variant of `actionKeyWidths`.
|
||||
public static func iPadActionKeyWidths(
|
||||
availableWidth: CGFloat
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// UI-owned ObservableObject; construct and mutate it on the main thread.
|
||||
/// `@unchecked Sendable` does not make `@Published` thread-safe. Credential
|
||||
/// observers write only Keychain, while non-secret configuration uses App Group defaults.
|
||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// UI-owned ObservableObject, isolated to the main actor. Credential observers
|
||||
/// write only Keychain, while non-secret configuration uses App Group defaults.
|
||||
@MainActor
|
||||
public final class ProviderConfig: ObservableObject {
|
||||
public static let shared = ProviderConfig()
|
||||
|
||||
@Published public var providerId: String {
|
||||
|
||||
@@ -29,6 +29,27 @@ public enum TypingSurfaceMetrics {
|
||||
public static let keyHorizontalSpacing: CGFloat = 6
|
||||
public static let secondRowInset: CGFloat = 18
|
||||
|
||||
// MARK: - Expanded phone (unfolded iPhone Duo inner display)
|
||||
|
||||
/// The system keyboard on the inner display keeps the iPhone key layout at
|
||||
/// ~38.5 pt rows inside a 263 pt panel, and the system adds ~23 pt of
|
||||
/// chrome around a custom input view. These rows give a 260 pt shell —
|
||||
/// still roomy for the voice surface's live transcript — so the panel
|
||||
/// stays ~20 pt above the system one instead of ~40 pt.
|
||||
public static let expandedPhoneKeyRowHeight: CGFloat = 41
|
||||
public static let expandedPhoneKeyRowSpacing: CGFloat = 12
|
||||
public static let expandedPhoneKeyHorizontalSpacing: CGFloat = 10
|
||||
|
||||
// MARK: - Compact-height phone (any iPhone in landscape, incl. Duo outer)
|
||||
|
||||
/// A landscape iPhone is only ~400–470 pt tall, so the 281 pt portrait
|
||||
/// panel would take 60–70% of it. These rows give a 244 pt shell — enough
|
||||
/// for the voice surface to centre its mic and still fit the 3-line live
|
||||
/// transcript above it — instead of following the system keyboard down
|
||||
/// to ~200 pt.
|
||||
public static let compactHeightPhoneKeyRowHeight: CGFloat = 40
|
||||
public static let compactHeightPhoneKeyRowSpacing: CGFloat = 8
|
||||
|
||||
// MARK: - iPad (narrow: portrait, or a compact-ish regular window)
|
||||
|
||||
public static let iPadKeyRowHeight: CGFloat = 54
|
||||
@@ -47,8 +68,43 @@ public enum TypingSurfaceMetrics {
|
||||
/// Key metrics for a device class and available width. Width — not device
|
||||
/// orientation — picks the wide bucket, so a resized Stage Manager window
|
||||
/// lands on the right metrics without consulting orientation at all.
|
||||
public static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
|
||||
/// `isExpandedPhone` / `isCompactHeightPhone` are only meaningful when
|
||||
/// `isIPad` is false, and never true together (regular vs compact height).
|
||||
public static func metrics(
|
||||
isIPad: Bool,
|
||||
width: CGFloat,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> TypingKeyLayoutBuilder.Metrics {
|
||||
guard isIPad else {
|
||||
if isCompactHeightPhone {
|
||||
return TypingKeyLayoutBuilder.Metrics(
|
||||
keyRowHeight: compactHeightPhoneKeyRowHeight,
|
||||
keyRowSpacing: compactHeightPhoneKeyRowSpacing,
|
||||
keyHorizontalSpacing: keyHorizontalSpacing,
|
||||
// Ignored: landscape keys are ~70 pt wide and need the
|
||||
// system's half-key indent, not the portrait 18 pt.
|
||||
secondRowInset: 0,
|
||||
bottomRowHeight: compactHeightPhoneKeyRowHeight,
|
||||
bottomActionSpacing: KeyboardChromeLayout.actionKeySpacing,
|
||||
gridToBottomSpacing: compactHeightPhoneKeyRowSpacing,
|
||||
derivesSecondRowInsetFromKeyWidth: true
|
||||
)
|
||||
}
|
||||
if isExpandedPhone {
|
||||
return TypingKeyLayoutBuilder.Metrics(
|
||||
keyRowHeight: expandedPhoneKeyRowHeight,
|
||||
keyRowSpacing: expandedPhoneKeyRowSpacing,
|
||||
keyHorizontalSpacing: expandedPhoneKeyHorizontalSpacing,
|
||||
// Ignored: wide keys need the system's half-key indent.
|
||||
secondRowInset: 0,
|
||||
bottomRowHeight: expandedPhoneKeyRowHeight,
|
||||
// Bottom-row widths are pre-split with this spacing.
|
||||
bottomActionSpacing: KeyboardChromeLayout.actionKeySpacing,
|
||||
gridToBottomSpacing: expandedPhoneKeyRowSpacing,
|
||||
derivesSecondRowInsetFromKeyWidth: true
|
||||
)
|
||||
}
|
||||
return TypingKeyLayoutBuilder.Metrics(
|
||||
keyRowHeight: keyRowHeight,
|
||||
keyRowSpacing: keyRowSpacing,
|
||||
@@ -77,9 +133,19 @@ public enum TypingSurfaceMetrics {
|
||||
|
||||
/// Content-driven keyboard height. iPad grows its rows, so the shell has to
|
||||
/// grow with them or the bottom row is clipped.
|
||||
public static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
|
||||
guard isIPad else { return KeyboardChromeLayout.totalHeight }
|
||||
let m = metrics(isIPad: true, width: width)
|
||||
public static func contentHeight(
|
||||
isIPad: Bool,
|
||||
width: CGFloat,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> CGFloat {
|
||||
guard isIPad || isExpandedPhone || isCompactHeightPhone else { return KeyboardChromeLayout.totalHeight }
|
||||
let m = metrics(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
let inner = topRegionHeight
|
||||
+ verticalKeySpacing
|
||||
+ (3 * m.keyRowHeight + 2 * m.keyRowSpacing)
|
||||
|
||||
@@ -316,6 +316,14 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
/// Legacy ID consolidated into `summarizeID`.
|
||||
public static let extractConclusionsID = "extractConclusions"
|
||||
public static let translateID = "translate"
|
||||
/// Translates text that is already in the device language into the user's
|
||||
/// configured translation target language. The mirror of `translateID`
|
||||
/// (which handles foreign text → device language); the keyboard offers one
|
||||
/// or the other depending on the copy's language, never both.
|
||||
public static let translateToTargetID = "translateToTarget"
|
||||
/// Fixes grammar, improves wording, and polishes foreign-language text while
|
||||
/// keeping it in its original language. Offered only for foreign copies.
|
||||
public static let polishOriginalID = "polishOriginal"
|
||||
public static let acceptInvitationID = "acceptInvitation"
|
||||
public static let declineInvitationID = "declineInvitation"
|
||||
public static let acceptTaskID = "acceptTask"
|
||||
@@ -416,6 +424,24 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: translateToTargetID,
|
||||
systemImage: "globe",
|
||||
titleKey: "keyboard.ai.skill.translateToTarget",
|
||||
cardTitleKey: "skills.translateToTarget.name",
|
||||
descriptionKey: "skills.translateToTarget.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: polishOriginalID,
|
||||
systemImage: "wand.and.stars",
|
||||
titleKey: "keyboard.ai.skill.polishOriginal",
|
||||
cardTitleKey: "skills.polishOriginal.name",
|
||||
descriptionKey: "skills.polishOriginal.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: openLinkID,
|
||||
systemImage: "arrow.up.right.square.fill",
|
||||
@@ -692,6 +718,14 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
now: now
|
||||
)
|
||||
}
|
||||
// Polishing rewrites the user's own draft, so it carries the same
|
||||
// never-answer boundary as "Speak as me" (built-in prompt only).
|
||||
if skill.id == polishOriginalID, skill.customPrompt == nil {
|
||||
let boundary = locale == "zh"
|
||||
? PolishPromptComposer.chineseNeverAnswerContract
|
||||
: PolishPromptComposer.englishNeverAnswerContract
|
||||
return "\(baseInstruction)\n\(boundary)"
|
||||
}
|
||||
if canonicalID(for: skill.id) == speakAsMeID {
|
||||
return speakAsMeInstruction(
|
||||
baseInstruction,
|
||||
@@ -724,10 +758,27 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
: "To \(target)"
|
||||
}
|
||||
|
||||
/// Chip label for "Translate to <target>". Nil when the user has translation
|
||||
/// turned off (or the id is unknown), in which case the skill is not offered.
|
||||
/// The language name is localized to the UI language ("英语" / "English").
|
||||
public static func translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: String,
|
||||
uiLanguage: AppUILanguage
|
||||
) -> String? {
|
||||
guard !TranslationLanguageCatalog.isOff(translationTargetLocaleId) else { return nil }
|
||||
let language = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
|
||||
let displayLocale = Locale(identifier: uiLanguage.resolvedLanguageCode())
|
||||
let name = displayLocale.localizedString(forIdentifier: language.id)
|
||||
?? language.nativeName
|
||||
return uiLanguage.resolvedLanguageCode() == "zh-Hans"
|
||||
? "翻译\(name)"
|
||||
: "To \(name)"
|
||||
}
|
||||
|
||||
public static func instruction(
|
||||
skillID: String,
|
||||
locale: String,
|
||||
translationTargetLocaleId _: String,
|
||||
translationTargetLocaleId: String,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages,
|
||||
now: Date = Date()
|
||||
) -> String {
|
||||
@@ -779,6 +830,15 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
locale: locale,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
case translateToTargetID:
|
||||
return translateToTargetInstruction(
|
||||
zh: zh,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
case polishOriginalID:
|
||||
return zh
|
||||
? "请在不改变语言的前提下优化剪贴板文本:先纠正语法、拼写和标点错误,再改善不自然或不准确的措辞,最后适度润色,让表达通顺、自然、得体。严格使用原文的语言输出,不要翻译;保留原意、事实、数字、人名、链接、代码和段落格式,不要扩写或删减要点。只输出优化后的文本,不要解释,也不要列出修改点。"
|
||||
: "Improve the clipboard text without changing its language: first fix grammar, spelling, and punctuation errors, then improve awkward or inaccurate wording, and finally polish lightly so it reads smoothly, naturally, and appropriately. Output strictly in the source language and never translate. Preserve the meaning, facts, numbers, names, links, code, and paragraph formatting; do not add or drop points. Output only the improved text, with no explanation and no list of changes."
|
||||
case businessReplyID:
|
||||
return zh
|
||||
? "请写一段专业但不官腔的商务聊天回复,表达直接、自然,保留人名、组织名、时间和承诺边界,可直接发送。不要套用正式邮件开场和结尾。"
|
||||
@@ -987,6 +1047,23 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
return "\(baseInstruction)\n\(conversationalBaseline)\(sceneInstruction)\n\(personalStyle)"
|
||||
}
|
||||
|
||||
/// Translate into the user's configured target language. An "off" or stale
|
||||
/// target falls back to the catalog default so the Skills-tab prompt preview
|
||||
/// never shows an empty language name.
|
||||
private static func translateToTargetInstruction(
|
||||
zh: Bool,
|
||||
translationTargetLocaleId: String
|
||||
) -> String {
|
||||
let resolved = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
|
||||
let language = TranslationLanguageCatalog.isOff(resolved.id)
|
||||
? TranslationLanguageCatalog.resolve(TranslationLanguageCatalog.defaultLocaleId)
|
||||
: resolved
|
||||
let target = language.promptLanguageName
|
||||
return zh
|
||||
? "请把剪贴板文本翻译成 \(target),准确保留原意、语气、名称、链接和格式;如果文本本身已经是 \(target),则原样输出。只输出译文,不要解释。"
|
||||
: "Translate the clipboard text into \(target), preserving meaning, tone, names, links, and formatting. If the text is already in \(target), return it unchanged. Output only the translation with no explanation."
|
||||
}
|
||||
|
||||
/// Clipboard translation always follows the device's primary system language.
|
||||
private static func translateInstruction(
|
||||
locale: String,
|
||||
|
||||
@@ -498,6 +498,12 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
if storedMigrationVersion < 8 {
|
||||
additionIDs.insert(AIClipboardSkillCatalog.blessingReplyID)
|
||||
}
|
||||
if storedMigrationVersion < 12 {
|
||||
// Translate-to-target and Polish ship as defaults; install them
|
||||
// once for existing layouts (a later opt-out stays opted out).
|
||||
additionIDs.insert(AIClipboardSkillCatalog.translateToTargetID)
|
||||
additionIDs.insert(AIClipboardSkillCatalog.polishOriginalID)
|
||||
}
|
||||
// v9 consolidates playful and business reply into Reply. Do not
|
||||
// add Reply here: `sanitized` preserves it only when any reply ID
|
||||
// was enabled, so a user's explicit disabled state stays disabled.
|
||||
@@ -529,7 +535,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 11
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 12
|
||||
|
||||
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
|
||||
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
|
||||
|
||||
@@ -40,7 +40,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
sourceText: String,
|
||||
analysis: ClipboardSemanticAnalysis,
|
||||
uiLanguage _: AppUILanguage,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
preferredLanguages: [String] = Locale.preferredLanguages,
|
||||
translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
) -> [AIClipboardSkill] {
|
||||
guard skills.count > 1 else { return skills }
|
||||
return sorted(
|
||||
@@ -48,7 +49,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
scores: relevanceScores(
|
||||
sourceText: sourceText,
|
||||
analysis: analysis,
|
||||
preferredLanguages: preferredLanguages
|
||||
preferredLanguages: preferredLanguages,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -61,13 +63,15 @@ public enum ClipboardSkillSemanticRanker {
|
||||
analysis: ClipboardSemanticAnalysis,
|
||||
uiLanguage _: AppUILanguage,
|
||||
limit: Int,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
preferredLanguages: [String] = Locale.preferredLanguages,
|
||||
translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
) -> [AIClipboardSkill] {
|
||||
guard limit > 0 else { return [] }
|
||||
var scores = relevanceScores(
|
||||
sourceText: sourceText,
|
||||
analysis: analysis,
|
||||
preferredLanguages: preferredLanguages
|
||||
preferredLanguages: preferredLanguages,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
let genericReply = suppressesInterpersonalRouting(analysis, sourceText: sourceText)
|
||||
? nil
|
||||
@@ -114,7 +118,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
private static func relevanceScores(
|
||||
sourceText: String,
|
||||
analysis: ClipboardSemanticAnalysis,
|
||||
preferredLanguages: [String]
|
||||
preferredLanguages: [String],
|
||||
translationTargetLocaleId: String
|
||||
) -> [String: Int] {
|
||||
var scores: [String: Int] = [:]
|
||||
func boost(_ id: String, _ value: Int) {
|
||||
@@ -149,7 +154,20 @@ public enum ClipboardSkillSemanticRanker {
|
||||
analysis.language,
|
||||
preferredLanguages: preferredLanguages
|
||||
) {
|
||||
// Foreign text: translate it into the device language (auto-translate
|
||||
// uses the same path) and offer to polish it in its own language.
|
||||
// Polish is a secondary option, so it ranks below a real Reply.
|
||||
boost(AIClipboardSkillCatalog.translateID, 230)
|
||||
boost(AIClipboardSkillCatalog.polishOriginalID, 100)
|
||||
} else if isDeviceLanguage(
|
||||
analysis.language,
|
||||
preferredLanguages: preferredLanguages
|
||||
), canTranslateToTarget(
|
||||
from: analysis.language,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
) {
|
||||
// Device-language text: offer the user's configured target language.
|
||||
boost(AIClipboardSkillCatalog.translateToTargetID, 225)
|
||||
}
|
||||
|
||||
if analysis.hasAddress {
|
||||
@@ -390,6 +408,39 @@ public enum ClipboardSkillSemanticRanker {
|
||||
)
|
||||
}
|
||||
|
||||
/// Confidently the device's primary language — the counterpart of
|
||||
/// `isLanguageMismatch`. An unknown / low-confidence label is neither.
|
||||
private static func isDeviceLanguage(
|
||||
_ language: ClipboardLanguageLabel?,
|
||||
preferredLanguages: [String]
|
||||
) -> Bool {
|
||||
guard let language, language.confidence >= languageConfidenceThreshold else {
|
||||
return false
|
||||
}
|
||||
return SystemLanguageResolver.isSameLanguage(
|
||||
sourceIdentifier: language.identifier,
|
||||
targetIdentifier: SystemLanguageResolver.primaryIdentifier(
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// "Translate to <target>" needs a real target that differs from the
|
||||
/// copy's own language; translating into the same language is a no-op.
|
||||
private static func canTranslateToTarget(
|
||||
from language: ClipboardLanguageLabel?,
|
||||
translationTargetLocaleId: String
|
||||
) -> Bool {
|
||||
guard let language,
|
||||
!TranslationLanguageCatalog.isOff(translationTargetLocaleId) else {
|
||||
return false
|
||||
}
|
||||
return !SystemLanguageResolver.isSameLanguage(
|
||||
sourceIdentifier: language.identifier,
|
||||
targetIdentifier: translationTargetLocaleId
|
||||
)
|
||||
}
|
||||
|
||||
private static func isRoutingEvidence(_ label: ClipboardIntentLabel) -> Bool {
|
||||
label.isDetected && label.isApprovedForAutomaticRouting
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//
|
||||
// Public URL-scheme whitelist for returning to a known host app after a
|
||||
// cold-start Flow session handoff. One bundle id maps to one preferred URL.
|
||||
//
|
||||
// Bundle ids are checked against the App Store lookup API. URL schemes have no
|
||||
// public source of truth; a wrong one only makes the open fail (the user stays
|
||||
// in OSG), it never sends the user to a different app.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -25,287 +29,159 @@ public struct HostAppEntry: Sendable, Equatable {
|
||||
}
|
||||
|
||||
public enum HostAppURLRegistry {
|
||||
/// Curated whitelist for high-frequency host apps (IM, work, notes).
|
||||
/// Curated whitelist: high-frequency typing hosts first, then the CN / US
|
||||
/// App Store top charts, then Apple system apps.
|
||||
public static let entries: [HostAppEntry] = [
|
||||
// Tier 1 — China IM / work
|
||||
HostAppEntry(
|
||||
bundleId: "com.tencent.xin",
|
||||
displayNameKey: "hostApp.wechat",
|
||||
returnURLString: "weixin://",
|
||||
tier: 1
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.tencent.mqq",
|
||||
displayNameKey: "hostApp.qq",
|
||||
returnURLString: "mqq://",
|
||||
tier: 1
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.tencent.wework",
|
||||
displayNameKey: "hostApp.wecom",
|
||||
returnURLString: "wxwork://",
|
||||
tier: 1
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.laiwang.DingTalk",
|
||||
displayNameKey: "hostApp.dingtalk",
|
||||
returnURLString: "dingtalk://",
|
||||
tier: 1
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.bytedance.ee.lark",
|
||||
displayNameKey: "hostApp.lark",
|
||||
returnURLString: "lark://",
|
||||
tier: 1
|
||||
),
|
||||
// Tier 2 — global IM / collaboration
|
||||
HostAppEntry(
|
||||
bundleId: "ph.telegra.Telegraph",
|
||||
displayNameKey: "hostApp.telegram",
|
||||
returnURLString: "tg://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "net.whatsapp.WhatsApp",
|
||||
displayNameKey: "hostApp.whatsapp",
|
||||
returnURLString: "whatsapp://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "jp.naver.line",
|
||||
displayNameKey: "hostApp.line",
|
||||
returnURLString: "line://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.facebook.Messenger",
|
||||
displayNameKey: "hostApp.messenger",
|
||||
returnURLString: "fb-messenger://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.tinyspeck.chatlyio",
|
||||
displayNameKey: "hostApp.slack",
|
||||
returnURLString: "slack://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.microsoft.skype.teams",
|
||||
displayNameKey: "hostApp.teams",
|
||||
returnURLString: "msteams://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.hammerandchisel.discord",
|
||||
displayNameKey: "hostApp.discord",
|
||||
returnURLString: "discord://",
|
||||
tier: 2
|
||||
),
|
||||
// Tier 3 — notes / mail / browser
|
||||
HostAppEntry(
|
||||
bundleId: "notion.id",
|
||||
displayNameKey: "hostApp.notion",
|
||||
returnURLString: "notion://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "net.shinyfrog.bear",
|
||||
displayNameKey: "hostApp.bear",
|
||||
returnURLString: "bear://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "md.obsidian",
|
||||
displayNameKey: "hostApp.obsidian",
|
||||
returnURLString: "obsidian://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.agiletortoise.Drafts5",
|
||||
displayNameKey: "hostApp.drafts",
|
||||
returnURLString: "drafts5://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.google.Gmail",
|
||||
displayNameKey: "hostApp.gmail",
|
||||
returnURLString: "googlegmail://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.microsoft.Office.Outlook",
|
||||
displayNameKey: "hostApp.outlook",
|
||||
returnURLString: "ms-outlook://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.google.chrome.ios",
|
||||
displayNameKey: "hostApp.chrome",
|
||||
returnURLString: "googlechrome://",
|
||||
tier: 3
|
||||
),
|
||||
// Tier 4 — China social
|
||||
HostAppEntry(
|
||||
bundleId: "com.sina.weibo",
|
||||
displayNameKey: "hostApp.weibo",
|
||||
returnURLString: "sinaweibo://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.xingin.discover",
|
||||
displayNameKey: "hostApp.xiaohongshu",
|
||||
returnURLString: "xhsdiscover://",
|
||||
tier: 4
|
||||
),
|
||||
// Tier 2 (cont.) — global IM / calls
|
||||
HostAppEntry(
|
||||
bundleId: "org.whispersystems.signal",
|
||||
displayNameKey: "hostApp.signal",
|
||||
returnURLString: "sgnl://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.iwilab.KakaoTalk",
|
||||
displayNameKey: "hostApp.kakaotalk",
|
||||
returnURLString: "kakaotalk://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.viber",
|
||||
displayNameKey: "hostApp.viber",
|
||||
returnURLString: "viber://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.vng.zaloapp",
|
||||
displayNameKey: "hostApp.zalo",
|
||||
returnURLString: "zalo://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.skype.skype",
|
||||
displayNameKey: "hostApp.skype",
|
||||
returnURLString: "skype://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "us.zoom.videomeetings",
|
||||
displayNameKey: "hostApp.zoom",
|
||||
returnURLString: "zoomus://",
|
||||
tier: 2
|
||||
),
|
||||
// Tier 3 (cont.) — notes / mail / browser
|
||||
HostAppEntry(
|
||||
bundleId: "com.culturedcode.ThingsiPhone",
|
||||
displayNameKey: "hostApp.things",
|
||||
returnURLString: "things://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.todoist.ios",
|
||||
displayNameKey: "hostApp.todoist",
|
||||
returnURLString: "todoist://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.evernote.iPhone.Evernote",
|
||||
displayNameKey: "hostApp.evernote",
|
||||
returnURLString: "evernote://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.microsoft.onenote",
|
||||
displayNameKey: "hostApp.onenote",
|
||||
returnURLString: "onenote://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.readdle.smartemail",
|
||||
displayNameKey: "hostApp.spark",
|
||||
returnURLString: "readdle-spark://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "org.mozilla.ios.Firefox",
|
||||
displayNameKey: "hostApp.firefox",
|
||||
returnURLString: "firefox://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.microsoft.msedge",
|
||||
displayNameKey: "hostApp.edge",
|
||||
returnURLString: "microsoft-edge://",
|
||||
tier: 3
|
||||
),
|
||||
// Tier 4 (cont.) — global / China social
|
||||
HostAppEntry(
|
||||
bundleId: "com.facebook.Facebook",
|
||||
displayNameKey: "hostApp.facebook",
|
||||
returnURLString: "fb://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.burbn.instagram",
|
||||
displayNameKey: "hostApp.instagram",
|
||||
returnURLString: "instagram://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.atebits.Tweetie2",
|
||||
displayNameKey: "hostApp.x",
|
||||
returnURLString: "twitter://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.burbn.barcelona",
|
||||
displayNameKey: "hostApp.threads",
|
||||
returnURLString: "barcelona://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.toyopagroup.picaboo",
|
||||
displayNameKey: "hostApp.snapchat",
|
||||
returnURLString: "snapchat://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.reddit.Reddit",
|
||||
displayNameKey: "hostApp.reddit",
|
||||
returnURLString: "reddit://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "pinterest",
|
||||
displayNameKey: "hostApp.pinterest",
|
||||
returnURLString: "pinterest://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.zhihu.ios",
|
||||
displayNameKey: "hostApp.zhihu",
|
||||
returnURLString: "zhihu://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "tv.danmaku.bili",
|
||||
displayNameKey: "hostApp.bilibili",
|
||||
returnURLString: "bilibili://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.ss.iphone.ugc.Aweme",
|
||||
displayNameKey: "hostApp.douyin",
|
||||
returnURLString: "snssdk1128://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.zhiliaoapp.musically",
|
||||
displayNameKey: "hostApp.tiktok",
|
||||
returnURLString: "tiktok://",
|
||||
tier: 4
|
||||
)
|
||||
app("com.tencent.xin", "wechat", "weixin://", tier: 1),
|
||||
app("com.tencent.mqq", "qq", "mqq://", tier: 1),
|
||||
app("com.tencent.ww", "wecom", "wxwork://", tier: 1),
|
||||
app("com.laiwang.DingTalk", "dingtalk", "dingtalk://", tier: 1),
|
||||
app("com.bytedance.ee.lark", "lark", "lark://", tier: 1),
|
||||
app("com.tencent.meeting", "tencentMeeting", "wemeet://", tier: 1),
|
||||
app("com.tencent.qqmail", "qqmail", "qqmail://", tier: 1),
|
||||
app("com.kingsoft.www.office.wpsoffice", "wps", "wpsoffice://", tier: 1),
|
||||
|
||||
// Tier 2 — global IM / calls / dating
|
||||
app("ph.telegra.Telegraph", "telegram", "tg://", tier: 2),
|
||||
app("net.whatsapp.WhatsApp", "whatsapp", "whatsapp://", tier: 2),
|
||||
app("jp.naver.line", "line", "line://", tier: 2),
|
||||
app("com.facebook.Messenger", "messenger", "fb-messenger://", tier: 2),
|
||||
app("com.tinyspeck.chatlyio", "slack", "slack://", tier: 2),
|
||||
app("com.microsoft.skype.teams", "teams", "msteams://", tier: 2),
|
||||
app("com.hammerandchisel.discord", "discord", "discord://", tier: 2),
|
||||
app("org.whispersystems.signal", "signal", "sgnl://", tier: 2),
|
||||
app("com.iwilab.KakaoTalk", "kakaotalk", "kakaotalk://", tier: 2),
|
||||
app("com.viber", "viber", "viber://", tier: 2),
|
||||
app("vn.com.vng.zingalo", "zalo", "zalo://", tier: 2),
|
||||
app("us.zoom.videomeetings", "zoom", "zoomus://", tier: 2),
|
||||
app("com.cardify.tinder", "tinder", "tinder://", tier: 2),
|
||||
|
||||
// Tier 3 — notes / mail / browser / office / AI
|
||||
app("notion.id", "notion", "notion://", tier: 3),
|
||||
app("net.shinyfrog.bear", "bear", "bear://", tier: 3),
|
||||
app("md.obsidian", "obsidian", "obsidian://", tier: 3),
|
||||
app("com.agiletortoise.Drafts5", "drafts", "drafts5://", tier: 3),
|
||||
app("com.culturedcode.ThingsiPhone", "things", "things://", tier: 3),
|
||||
app("com.todoist.ios", "todoist", "todoist://", tier: 3),
|
||||
app("com.evernote.iPhone.Evernote", "evernote", "evernote://", tier: 3),
|
||||
app("com.microsoft.onenote", "onenote", "onenote://", tier: 3),
|
||||
app("com.microsoft.Office.Word", "word", "ms-word://", tier: 3),
|
||||
app("com.microsoft.Office.Excel", "excel", "ms-excel://", tier: 3),
|
||||
app("com.google.Gmail", "gmail", "googlegmail://", tier: 3),
|
||||
app("com.microsoft.Office.Outlook", "outlook", "ms-outlook://", tier: 3),
|
||||
app("com.readdle.smartemail", "spark", "readdle-spark://", tier: 3),
|
||||
app("com.yahoo.Aerogram", "yahooMail", "ymail://", tier: 3),
|
||||
app("com.google.Docs", "googleDocs", "googledocs://", tier: 3),
|
||||
app("com.google.Sheets", "googleSheets", "googlesheets://", tier: 3),
|
||||
app("com.google.Drive", "googleDrive", "googledrive://", tier: 3),
|
||||
app("com.google.Translate", "googleTranslate", "googletranslate://", tier: 3),
|
||||
app("com.google.GoogleMobile", "google", "google://", tier: 3),
|
||||
app("com.google.chrome.ios", "chrome", "googlechrome://", tier: 3),
|
||||
app("org.mozilla.ios.Firefox", "firefox", "firefox://", tier: 3),
|
||||
app("com.microsoft.msedge", "edge", "microsoft-edge://", tier: 3),
|
||||
app("com.openai.chat", "chatgpt", "chatgpt://", tier: 3),
|
||||
app("com.anthropic.claude", "claude", "claude://", tier: 3),
|
||||
|
||||
// Tier 4 — China social / content
|
||||
app("com.sina.weibo", "weibo", "sinaweibo://", tier: 4),
|
||||
app("com.xingin.discover", "xiaohongshu", "xhsdiscover://", tier: 4),
|
||||
app("com.zhihu.ios", "zhihu", "zhihu://", tier: 4),
|
||||
app("tv.danmaku.bilianime", "bilibili", "bilibili://", tier: 4),
|
||||
app("com.ss.iphone.ugc.Aweme", "douyin", "snssdk1128://", tier: 4),
|
||||
app("com.ss.iphone.ugc.aweme.lite", "douyinLite", "snssdk2329://", tier: 4),
|
||||
app("com.jiangjia.gif", "kuaishou", "kwai://", tier: 4),
|
||||
app("com.kuaishou.nebula", "kuaishouLite", "ksnebula://", tier: 4),
|
||||
app("com.ss.iphone.article.News", "toutiao", "snssdk141://", tier: 4),
|
||||
app("com.douban.frodo", "douban", "douban://", tier: 4),
|
||||
app("com.baidu.BaiduMobile", "baidu", "baiduboxapp://", tier: 4),
|
||||
app("com.youku.YouKu", "youku", "youku://", tier: 4),
|
||||
app("com.tencent.live4iphone", "tencentVideo", "tenvideo://", tier: 4),
|
||||
app("com.qiyi.iphone", "iqiyi", "iqiyi://", tier: 4),
|
||||
app("com.hunantv.imgotv", "mangoTV", "imgotv://", tier: 4),
|
||||
app("com.tencent.QQMusic", "qqMusic", "qqmusic://", tier: 4),
|
||||
app("com.netease.cloudmusic", "neteaseMusic", "orpheus://", tier: 4),
|
||||
app("com.kugou.kugou1002", "kugou", "kugouURL://", tier: 4),
|
||||
app("com.gemd.iting", "ximalaya", "iting://", tier: 4),
|
||||
app("com.tencent.weread", "weread", "weread://", tier: 4),
|
||||
|
||||
// Tier 4 — global social / content
|
||||
app("com.facebook.Facebook", "facebook", "fb://", tier: 4),
|
||||
app("com.burbn.instagram", "instagram", "instagram://", tier: 4),
|
||||
app("com.atebits.Tweetie2", "x", "twitter://", tier: 4),
|
||||
app("com.burbn.barcelona", "threads", "barcelona://", tier: 4),
|
||||
app("com.toyopagroup.picaboo", "snapchat", "snapchat://", tier: 4),
|
||||
app("com.reddit.Reddit", "reddit", "reddit://", tier: 4),
|
||||
app("pinterest", "pinterest", "pinterest://", tier: 4),
|
||||
app("com.zhiliaoapp.musically", "tiktok", "tiktok://", tier: 4),
|
||||
app("com.google.ios.youtube", "youtube", "youtube://", tier: 4),
|
||||
app("com.linkedin.LinkedIn", "linkedin", "linkedin://", tier: 4),
|
||||
app("com.tumblr.tumblr", "tumblr", "tumblr://", tier: 4),
|
||||
app("tv.twitch", "twitch", "twitch://", tier: 4),
|
||||
app("com.spotify.client", "spotify", "spotify://", tier: 4),
|
||||
app("com.netflix.Netflix", "netflix", "nflx://", tier: 4),
|
||||
app("com.lemon.lvoverseas", "capcut", "capcut://", tier: 4),
|
||||
app("com.duolingo.DuolingoMobile", "duolingo", "duolingo://", tier: 4),
|
||||
|
||||
// Tier 5 — China shopping / local services / travel / finance
|
||||
app("com.taobao.taobao4iphone", "taobao", "taobao://", tier: 5),
|
||||
app("com.taobao.tmall", "tmall", "tmall://", tier: 5),
|
||||
app("com.360buy.jdmobile", "jd", "openapp.jdmobile://", tier: 5),
|
||||
app("com.xunmeng.pinduoduo", "pinduoduo", "pinduoduo://", tier: 5),
|
||||
app("com.taobao.fleamarket", "xianyu", "fleamarket://", tier: 5),
|
||||
app("com.alibaba.wireless", "alibaba1688", "wireless1688://", tier: 5),
|
||||
app("com.vipshop.iphone", "vipshop", "vipshop://", tier: 5),
|
||||
app("com.meituan.imeituan", "meituan", "imeituan://", tier: 5),
|
||||
app("com.meituan.itakeaway", "meituanWaimai", "meituanwaimai://", tier: 5),
|
||||
app("me.ele.ios.eleme", "eleme", "eleme://", tier: 5),
|
||||
app("com.dianping.dpscope", "dianping", "dianping://", tier: 5),
|
||||
app("com.taofang.iphone", "wuba", "wbmain://", tier: 5),
|
||||
app("com.xiaojukeji.didi", "didi", "diditaxi://", tier: 5),
|
||||
app("com.autonavi.amap", "amap", "iosamap://", tier: 5),
|
||||
app("com.baidu.map", "baiduMap", "baidumap://", tier: 5),
|
||||
app("com.tencent.sosomap", "tencentMap", "qqmap://", tier: 5),
|
||||
app("ctrip.com", "ctrip", "ctrip://", tier: 5),
|
||||
app("com.baidu.netdisk", "baiduNetdisk", "baiduyun://", tier: 5),
|
||||
app("com.ucweb.iphone.lowversion", "ucBrowser", "ucbrowser://", tier: 5),
|
||||
app("com.tencent.mttlite", "qqBrowser", "mttbrowser://", tier: 5),
|
||||
app("com.alipay.iphoneclient", "alipay", "alipay://", tier: 5),
|
||||
app("com.unionpay.chsp", "unionpay", "upwallet://", tier: 5),
|
||||
app("com.cmbchina.MPBBank", "cmb", "cmbmobilebank://", tier: 5),
|
||||
app("com.ccb.ccbDemo", "ccb", "ccbmobilebank://", tier: 5),
|
||||
app("com.bankabc.iphonerelease", "abc", "bankabc://", tier: 5),
|
||||
|
||||
// Tier 5 — US shopping / local services / finance
|
||||
app("com.amazon.Amazon", "amazon", "com.amazon.mobile.shopping://", tier: 5),
|
||||
app("com.walmart.electronics", "walmart", "walmart://", tier: 5),
|
||||
app("com.ebay.iphone", "ebay", "ebay://", tier: 5),
|
||||
app("com.alibaba.iAliexpress", "aliexpress", "aliexpress://", tier: 5),
|
||||
app("com.ubercab.UberClient", "uber", "uber://", tier: 5),
|
||||
app("com.zimride.instant", "lyft", "lyft://", tier: 5),
|
||||
app("doordash.DoorDashConsumer", "doordash", "doordash://", tier: 5),
|
||||
app("com.airbnb.app", "airbnb", "airbnb://", tier: 5),
|
||||
app("com.google.Maps", "googleMaps", "comgooglemaps://", tier: 5),
|
||||
app("com.yourcompany.PPClient", "paypal", "paypal://", tier: 5),
|
||||
app("net.kortina.labs.Venmo", "venmo", "venmo://", tier: 5),
|
||||
app("com.squareup.cash", "cashApp", "cashme://", tier: 5),
|
||||
|
||||
// Tier 6 — Apple system apps. Safari has no scheme that reopens the
|
||||
// current tab (every https/x-web-search URL opens a new page), so it is
|
||||
// deliberately absent.
|
||||
app("com.apple.MobileSMS", "messages", "messages://", tier: 6),
|
||||
app("com.apple.mobilenotes", "notes", "mobilenotes://", tier: 6),
|
||||
app("com.apple.mobilemail", "mail", "message://", tier: 6),
|
||||
app("com.apple.reminders", "reminders", "x-apple-reminderkit://", tier: 6),
|
||||
app("com.apple.mobilecal", "calendar", "calshow://", tier: 6),
|
||||
app("com.apple.shortcuts", "shortcuts", "shortcuts://", tier: 6),
|
||||
app("com.apple.DocumentsApp", "files", "shareddocuments://", tier: 6),
|
||||
app("com.apple.Maps", "maps", "maps://", tier: 6),
|
||||
app("com.apple.AppStore", "appStore", "itms-apps://", tier: 6)
|
||||
]
|
||||
|
||||
private static func app(_ bundleId: String, _ name: String, _ url: String, tier: Int) -> HostAppEntry {
|
||||
HostAppEntry(bundleId: bundleId, displayNameKey: "hostApp.\(name)", returnURLString: url, tier: tier)
|
||||
}
|
||||
|
||||
private static let byBundleId: [String: HostAppEntry] = {
|
||||
Dictionary(uniqueKeysWithValues: entries.map { ($0.bundleId, $0) })
|
||||
}()
|
||||
@@ -315,15 +191,10 @@ public enum HostAppURLRegistry {
|
||||
return byBundleId[bundleId]
|
||||
}
|
||||
|
||||
/// URL schemes declared in `LSApplicationQueriesSchemes` for `canOpenURL`.
|
||||
public static var querySchemes: [String] {
|
||||
Array(
|
||||
Set(
|
||||
entries.compactMap { entry -> String? in
|
||||
guard let url = entry.returnURL, let scheme = url.scheme else { return nil }
|
||||
return scheme
|
||||
}
|
||||
)
|
||||
).sorted()
|
||||
/// OSG's own targets (host app, keyboard, Mac) are never a return destination.
|
||||
public static func isOwnBundleId(_ bundleId: String) -> Bool {
|
||||
bundleId == ownBundleIdRoot || bundleId.hasPrefix(ownBundleIdRoot + ".")
|
||||
}
|
||||
|
||||
private static let ownBundleIdRoot = "com.osgkeyboard"
|
||||
}
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
// (`KeyboardViewController`) re-exports the same type as a typealias so
|
||||
// existing call sites (`KeyboardViewController.State`) keep compiling.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// `@Observable` so each view re-renders only for the properties it reads —
|
||||
/// the 200 ms mic `level` tick no longer invalidates the whole keyboard.
|
||||
@MainActor
|
||||
public final class KeyboardState: ObservableObject {
|
||||
@Observable
|
||||
public final class KeyboardState {
|
||||
public init() {}
|
||||
|
||||
/// Full-height clipboard UI layered over the active keyboard surface.
|
||||
@@ -89,47 +92,57 @@ public final class KeyboardState: ObservableObject {
|
||||
public var id: String { rawValue }
|
||||
}
|
||||
|
||||
@Published public var phase: Phase = .idle
|
||||
public var phase: Phase = .idle {
|
||||
didSet {
|
||||
// A timeline only describes the current processing wait.
|
||||
if phase != .processing, dictationProgress != nil {
|
||||
dictationProgress = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Dictation wait shown as the microphone capsule's progress fill.
|
||||
/// Nil outside dictation processing (AI, edit, and cancellation waits).
|
||||
public var dictationProgress: DictationProgressTimeline?
|
||||
/// Active chrome. Forced to `.voice` while recording / processing.
|
||||
@Published public var surface: Surface = .voice
|
||||
public var surface: Surface = .voice
|
||||
/// Surface the user was on when auto mode switched them to `.voice` to show
|
||||
/// an auto-reply. Restored when the reply is closed; nil when not auto-routed.
|
||||
public var autoReplyReturnSurface: Surface?
|
||||
@Published public var level: Double = 0
|
||||
@Published public var mode: InputMode = .polish
|
||||
@Published public var localeId: String = "auto"
|
||||
@Published public var lastTranscript: String = ""
|
||||
@ObservationIgnored public var autoReplyReturnSurface: Surface?
|
||||
public var level: Double = 0
|
||||
public var mode: InputMode = .polish
|
||||
public var localeId: String = "auto"
|
||||
public var lastTranscript: String = ""
|
||||
/// `true` if the active ASR session is running on-device for the
|
||||
/// current locale. With iOS 26's `SpeechAnalyzer` this is always
|
||||
/// `true` — kept on the state object because the UI's status
|
||||
/// badge still wants a single source of truth to read from.
|
||||
@Published public var onDeviceSupported: Bool = false
|
||||
public var onDeviceSupported: Bool = false
|
||||
/// Seconds remaining in the current utterance (Flow tap-to-talk).
|
||||
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||
public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||
/// Whether the host app's Flow voice session is live and reachable (fresh
|
||||
/// heartbeat). Do not use the App Group session flag alone for UI gating.
|
||||
/// Prefer `micVoiceAvailability` for mic color and tap behavior.
|
||||
@Published public var flowSessionActive: Bool = false
|
||||
public var flowSessionActive: Bool = false
|
||||
/// Unified mic color / tap / hint source for the keyboard extension.
|
||||
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
|
||||
public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
|
||||
/// Short-lived host-owned practice mode. It unlocks real dictation before
|
||||
/// onboarding completion, but only while the onboarding text field is live.
|
||||
@Published public var isOnboardingPracticeActive: Bool = false
|
||||
public var isOnboardingPracticeActive: Bool = false
|
||||
/// Current strict OOBE contract used to render feature-specific extension
|
||||
/// state and bind completion to the host-owned session ID.
|
||||
@Published public var oobePracticeSession: OOBEPracticeSession?
|
||||
public var oobePracticeSession: OOBEPracticeSession?
|
||||
/// When true, the mic is intentionally disabled (e.g. cloud engine
|
||||
/// selected but the provider-specific API key is missing).
|
||||
@Published public var micDisabled: Bool = false
|
||||
public var micDisabled: Bool = false
|
||||
/// AI mode always needs an LLM even when local ASR keeps voice dictation usable.
|
||||
@Published public var aiServiceAvailable: Bool = true
|
||||
public var aiServiceAvailable: Bool = true
|
||||
/// One-line helper shown above the mic while `micDisabled == true`.
|
||||
@Published public var micDisabledHint: String = ""
|
||||
public var micDisabledHint: String = ""
|
||||
/// "local" → on-device ASR only. "cloud" → cloud ASR + LLM polish.
|
||||
/// Boot value must match the privacy-safe app default (`local`) so the
|
||||
/// keyboard never assumes the audio-uploading engine before the App
|
||||
/// Group config has been read.
|
||||
@Published public var engineMode: String = "local"
|
||||
public var engineMode: String = "local"
|
||||
/// Derived: translation is on iff a target
|
||||
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
|
||||
/// so the chip / pipeline read the same source of truth).
|
||||
@@ -140,122 +153,139 @@ public final class KeyboardState: ObservableObject {
|
||||
/// produce (e.g. `"en"`, `"ja"`). Mirrored from `ProviderConfig`.
|
||||
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
||||
/// state on first install.
|
||||
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
/// Mirrored from App Group — swaps delete / space on the bottom row.
|
||||
@Published public var handednessPreference: HandednessPreference = .left
|
||||
public var handednessPreference: HandednessPreference = .left
|
||||
/// Mirrors the host field's return-key intent. The action stays a newline
|
||||
/// insert; host apps decide whether that submits or creates a line break.
|
||||
@Published public var returnKeyRole: ReturnKeyRole = .newline
|
||||
public var returnKeyRole: ReturnKeyRole = .newline
|
||||
/// Opt-in clipboard history capture (mirrored from App Group).
|
||||
@Published public var clipboardHistoryEnabled: Bool = false
|
||||
public var clipboardHistoryEnabled: Bool = false
|
||||
/// Current extension permission state. Clipboard history remains readable
|
||||
/// without Full Access, but new system pasteboard entries cannot be captured.
|
||||
@Published public var hasFullAccess: Bool = false
|
||||
public var hasFullAccess: Bool = false
|
||||
public var needsClipboardFullAccessHint: Bool {
|
||||
clipboardHistoryEnabled && !hasFullAccess
|
||||
}
|
||||
/// Opt-in clipboard suggestion strip (requires history enabled).
|
||||
@Published public var clipboardCandidateBarEnabled: Bool = false
|
||||
public var clipboardCandidateBarEnabled: Bool = false
|
||||
/// Opt-in auto mode: a freshly copied replyable message is auto-routed into
|
||||
/// the Reply flow when the keyboard opens (requires history enabled).
|
||||
@Published public var clipboardAutoModeEnabled: Bool = false
|
||||
public var clipboardAutoModeEnabled: Bool = false
|
||||
/// Opt-in: auto-translate a freshly copied non-system-language paste on open.
|
||||
@Published public var clipboardAutoTranslateEnabled: Bool = false
|
||||
public var clipboardAutoTranslateEnabled: Bool = false
|
||||
/// Opt-in: auto-draft a reply when a freshly copied paste is an email.
|
||||
@Published public var clipboardAutoEmailReplyEnabled: Bool = false
|
||||
public var clipboardAutoEmailReplyEnabled: Bool = false
|
||||
/// When true, the current AI result is a *staged* auto result (auto-translate
|
||||
/// or auto-email-reply): shown on the keyboard and inserted only when the
|
||||
/// user taps the glass Insert button — never dropped straight into the field.
|
||||
@Published public var autoResultReadOnly: Bool = false
|
||||
public var autoResultReadOnly: Bool = false
|
||||
/// Localization key for the staged auto result's glass Insert button, so the
|
||||
/// label matches the action (e.g. "Insert translation" vs "Insert reply").
|
||||
@Published public var autoResultInsertLabelKey: String = "keyboard.assistant.insertTranslation"
|
||||
public var autoResultInsertLabelKey: String = "keyboard.assistant.insertTranslation"
|
||||
/// Clipboard skill that produced the currently shown result (reply / translate
|
||||
/// / …). The result panel's top bar offers the *other* applicable skills, so
|
||||
/// this one is excluded from that row. Nil when no clipboard result is active.
|
||||
@Published public var activeClipboardSkillID: String?
|
||||
public var activeClipboardSkillID: String?
|
||||
/// Skills-tab order for clipboard chips. Empty → hint carousel.
|
||||
@Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
|
||||
public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
|
||||
/// Fully resolved enabled skills. Mirroring value-semantic content here
|
||||
/// ensures Darwin updates publish prompt/name changes even when IDs stay unchanged.
|
||||
@Published public var enabledClipboardSkills: [AIClipboardSkill] =
|
||||
public var enabledClipboardSkills: [AIClipboardSkill] =
|
||||
AIClipboardSkillCatalog.visible()
|
||||
/// Complete resolved catalog used for semantic recommendations. Keyboard
|
||||
/// visibility is independent from the Skills page installation state.
|
||||
@Published public var clipboardSkillCatalog: [AIClipboardSkill] =
|
||||
public var clipboardSkillCatalog: [AIClipboardSkill] =
|
||||
AIClipboardSkillCatalog.catalog
|
||||
/// User-owned speaking style injected only into conversational reply skills.
|
||||
/// Built-in polish personalities intentionally leave this nil.
|
||||
@Published public var clipboardReplyStyle: AIClipboardReplyStyleContext?
|
||||
public var clipboardReplyStyle: AIClipboardReplyStyleContext?
|
||||
/// App Group setting: one Reply request should return three selectable tones.
|
||||
@Published public var multipleReplyVariantsEnabled: Bool = false
|
||||
public var multipleReplyVariantsEnabled: Bool = false
|
||||
/// Export skills whose companion Shortcut setup the user confirmed.
|
||||
@Published public var confirmedClipboardShortcutIDs: [String] = []
|
||||
public var confirmedClipboardShortcutIDs: [String] = []
|
||||
/// App language captured with the same App Group snapshot as skill copy.
|
||||
@Published public var uiLanguage: AppUILanguage = .auto
|
||||
public var uiLanguage: AppUILanguage = .auto
|
||||
/// Export skill currently waiting on the LLM. Nil for transform skills.
|
||||
@Published public var pendingClipboardSkillID: String?
|
||||
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?
|
||||
@ObservationIgnored public var pendingClipboardSkillSource: String?
|
||||
/// In-keyboard toast (e.g. no todos). Does not leave the host app.
|
||||
@Published public var skillTipText: String?
|
||||
public var skillTipText: String?
|
||||
/// Host field is a password / secure entry — never read pasteboard.
|
||||
@Published public var isSecureTextEntry: Bool = false
|
||||
public var isSecureTextEntry: Bool = false
|
||||
/// Secure fields hide every clipboard-history entry point.
|
||||
public var canShowClipboardEntry: Bool {
|
||||
!isSecureTextEntry
|
||||
}
|
||||
/// Full-keyboard clipboard overlay (enable guide or history list).
|
||||
@Published public var clipboardOverlay: ClipboardKeyboardOverlay = .none
|
||||
public var clipboardOverlay: ClipboardKeyboardOverlay = .none
|
||||
/// Suggestion strip above keys (newest clipboard item).
|
||||
@Published public var clipboardSuggestionText: String?
|
||||
public var clipboardSuggestionText: String?
|
||||
/// Pasteboard changeCount associated with the current suggestion (for dismiss).
|
||||
@Published public var clipboardSuggestionChangeCount: Int?
|
||||
public var clipboardSuggestionChangeCount: Int?
|
||||
/// Newest clipboard entry the user dismissed from the AI skill top bar.
|
||||
/// Shared (not view-local) so the dismissal survives surface switches —
|
||||
/// otherwise recreating `AIKeyboardView` would resurrect the skill bar
|
||||
/// while the paste suggestion stays suppressed. Reset when a new entry
|
||||
/// arrives so a fresh copy re-shows the skills.
|
||||
@Published public var dismissedClipboardSkillEntryID: UUID?
|
||||
public var dismissedClipboardSkillEntryID: UUID?
|
||||
/// Typing-grid haptic strength (off / light / strong).
|
||||
@Published public var keyboardHapticIntensity: KeyboardHapticIntensity = .default
|
||||
public var keyboardHapticIntensity: KeyboardHapticIntensity = .default
|
||||
/// Single source of truth for selecting iPad-scale keyboard metrics.
|
||||
/// The view controller resolves this from device idiom + horizontal size
|
||||
/// class so SwiftUI and the UIKit height constraint cannot disagree.
|
||||
@Published public var usesIPadLayoutMetrics: Bool = false
|
||||
/// The custom system-keyboard switch is iPad-only. iPhone relies on the
|
||||
/// system-provided switch below the keyboard instead of showing a duplicate.
|
||||
@Published public var showsSystemGlobeKey: Bool = false
|
||||
public var usesIPadLayoutMetrics: Bool = false
|
||||
/// Unfolded iPhone Duo: phone key layout on rows sized to the system
|
||||
/// keyboard of the inner display. Resolved alongside
|
||||
/// `usesIPadLayoutMetrics`; never true at the same time.
|
||||
public var usesExpandedPhoneLayoutMetrics: Bool = false
|
||||
/// Any iPhone in landscape (vertically compact): a shorter phone panel.
|
||||
/// Never true together with the iPad or expanded-phone metrics.
|
||||
public var usesCompactHeightPhoneLayoutMetrics: Bool = false
|
||||
/// Whether the keyboard draws its own globe key: always on iPad, and
|
||||
/// elsewhere only when UIKit reports no system switch below the keyboard.
|
||||
public var showsSystemGlobeKey: Bool = false
|
||||
/// Width the controller sized the keyboard to. Both the UIKit height
|
||||
/// constraint and the SwiftUI key grid pick their metrics from this one
|
||||
/// value so they can never disagree and clip the bottom row.
|
||||
@Published public var layoutWidth: CGFloat = 0
|
||||
public var layoutWidth: CGFloat = 0
|
||||
/// Wide hosts swap the voice surface's circle cluster for a keyboard-style
|
||||
/// bottom row aligned with the typing keys (see `VoiceKeyRowLayout`).
|
||||
public var usesVoiceKeyRow: Bool {
|
||||
VoiceKeyRowLayout.isAvailable(
|
||||
usesIPadMetrics: usesIPadLayoutMetrics,
|
||||
usesExpandedPhoneMetrics: usesExpandedPhoneLayoutMetrics,
|
||||
usesCompactHeightPhoneMetrics: usesCompactHeightPhoneLayoutMetrics,
|
||||
hostWidth: layoutWidth
|
||||
)
|
||||
}
|
||||
/// `true` when the last voice insertion is still at the caret and can
|
||||
/// be undone (suffix-checked against `documentContextBeforeInput`).
|
||||
@Published public var undoAvailable: Bool = false
|
||||
public var undoAvailable: Bool = false
|
||||
/// A verified OSG-generated insertion can be edited by voice.
|
||||
@Published public var editAvailable: Bool = false
|
||||
public var editAvailable: Bool = false
|
||||
/// `true` while an undone voice insertion can be re-applied (redo buffer).
|
||||
@Published public var redoAvailable: Bool = false
|
||||
public var redoAvailable: Bool = false
|
||||
/// `true` when the host field has a non-empty selection (copy enabled).
|
||||
@Published public var copyAvailable: Bool = false
|
||||
public var copyAvailable: Bool = false
|
||||
/// `true` when the host field has a non-empty selection (cut enabled).
|
||||
@Published public var cutAvailable: Bool = false
|
||||
public var cutAvailable: Bool = false
|
||||
/// Closed state machine for long-press editing of the last insertion.
|
||||
@Published public var editSession: EditSessionState = .inactive
|
||||
public var editSession: EditSessionState = .inactive
|
||||
/// AI conversation UI state for the keyboard surface. The host owns the actual messages.
|
||||
@Published public var aiSession: AISessionState = .inactive
|
||||
public var aiSession: AISessionState = .inactive
|
||||
/// Whether the assistant's field action can invoke the host Return key.
|
||||
/// Derived from the focused field's role and `UITextDocumentProxy.hasText`.
|
||||
@Published public var assistantActionAvailable: Bool = false
|
||||
public var assistantActionAvailable: Bool = false
|
||||
/// Brief success pulse rendered on the unified assistant microphone.
|
||||
@Published public var assistantInsertionSucceeded: Bool = false
|
||||
@Published public var editCanReplaceOriginal: Bool = false
|
||||
public var assistantInsertionSucceeded: Bool = false
|
||||
public var editCanReplaceOriginal: Bool = false
|
||||
/// Short idle feedback (availability, expiry, missing LLM).
|
||||
@Published public var editHint: String?
|
||||
public var editHint: String?
|
||||
/// Availability hints use the green accent; failures keep warning styling.
|
||||
@Published public var editHintIsPositive: Bool = false
|
||||
public var editHintIsPositive: Bool = false
|
||||
/// Whether translate-and-polish is armed for the current engine.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
@@ -278,7 +308,7 @@ public final class KeyboardState: ObservableObject {
|
||||
/// Mirrored from App Group / Keychain. Setup UI lives only in the host
|
||||
/// app; the keyboard uses this flag to gate voice (mic / Flow cold-start)
|
||||
/// and prompt a jump back to the app when incomplete.
|
||||
@Published public var hasCompletedOnboarding: Bool = false
|
||||
public var hasCompletedOnboarding: Bool = false
|
||||
|
||||
public enum ReturnKeyRole: Equatable {
|
||||
case newline
|
||||
@@ -369,76 +399,76 @@ public final class KeyboardState: ObservableObject {
|
||||
}
|
||||
|
||||
// Action hooks — injected by the view controller at install time.
|
||||
public var beginRecording: () -> Void = {}
|
||||
public var endRecording: () -> Void = {}
|
||||
public var tapMic: () -> Void = {}
|
||||
@ObservationIgnored public var beginRecording: () -> Void = {}
|
||||
@ObservationIgnored public var endRecording: () -> Void = {}
|
||||
@ObservationIgnored public var tapMic: () -> Void = {}
|
||||
/// Starts/cancels a bounded host-audio prime from the user's mic touch.
|
||||
public var setMicTouchActive: (Bool) -> Void = { _ in }
|
||||
@ObservationIgnored public var setMicTouchActive: (Bool) -> Void = { _ in }
|
||||
/// Discards the complete normal-dictation round, including late ASR/LLM output.
|
||||
public var cancelVoiceInput: () -> Void = {}
|
||||
public var beginEditLastInput: () -> Void = {}
|
||||
public var stopEditListening: () -> Void = {}
|
||||
public var confirmEditResult: () -> Void = {}
|
||||
public var closeEditMode: () -> Void = {}
|
||||
public var tapAIMic: () -> Void = {}
|
||||
public var cancelAIInput: () -> Void = {}
|
||||
@ObservationIgnored public var cancelVoiceInput: () -> Void = {}
|
||||
@ObservationIgnored public var beginEditLastInput: () -> Void = {}
|
||||
@ObservationIgnored public var stopEditListening: () -> Void = {}
|
||||
@ObservationIgnored public var confirmEditResult: () -> Void = {}
|
||||
@ObservationIgnored public var closeEditMode: () -> Void = {}
|
||||
@ObservationIgnored public var tapAIMic: () -> Void = {}
|
||||
@ObservationIgnored public var cancelAIInput: () -> Void = {}
|
||||
/// Explicitly inserts a retained AI result after target validation failed.
|
||||
public var confirmPendingAIAnswer: () -> Void = {}
|
||||
@ObservationIgnored public var confirmPendingAIAnswer: () -> Void = {}
|
||||
/// Inserts the complete tapped reply card. The UUID remains available on
|
||||
/// `aiSession.selectedReplyVariant` for a future feedback store.
|
||||
public var selectAIReplyVariant: (UUID) -> Void = { _ in }
|
||||
public var discardPendingAIAnswer: () -> Void = {}
|
||||
@ObservationIgnored public var selectAIReplyVariant: (UUID) -> Void = { _ in }
|
||||
@ObservationIgnored public var discardPendingAIAnswer: () -> Void = {}
|
||||
/// Performs the focused field's semantic Return action.
|
||||
public var performAssistantFieldAction: () -> Void = {}
|
||||
@ObservationIgnored public var performAssistantFieldAction: () -> Void = {}
|
||||
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
||||
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
||||
@ObservationIgnored public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
||||
/// Sends a clipboard skill plus any source-bound Reply scene modifier.
|
||||
public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> Void = { _, _ in }
|
||||
@ObservationIgnored public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> Void = { _, _ in }
|
||||
/// Writes extract-todos titles and opens the host to run the Shortcut.
|
||||
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
|
||||
public var openSettings: () -> Void = {}
|
||||
@ObservationIgnored public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
|
||||
@ObservationIgnored public var openSettings: () -> Void = {}
|
||||
/// Opens the host app straight to input-resource deployment. Used by the
|
||||
/// typing surface when Rime resources have not been deployed yet.
|
||||
public var openInputMethodSetup: () -> Void = {}
|
||||
@ObservationIgnored public var openInputMethodSetup: () -> Void = {}
|
||||
/// Opens the host app Settings → Clipboard page (enable history toggle).
|
||||
public var openClipboardSettings: () -> Void = {}
|
||||
@ObservationIgnored public var openClipboardSettings: () -> Void = {}
|
||||
/// Confirms the full-keyboard "try AI auto-reply" guide: turns auto mode on
|
||||
/// (persisted), dismisses the layer, and drafts a reply for the current copy.
|
||||
public var tryAutoReplyFromGuide: () -> Void = {}
|
||||
@ObservationIgnored public var tryAutoReplyFromGuide: () -> Void = {}
|
||||
/// Top-bar clipboard button: guide when history off, else history panel.
|
||||
public var openClipboardPanel: () -> Void = {}
|
||||
public var dismissClipboardOverlay: () -> Void = {}
|
||||
public var insertClipboardText: (String) -> Void = { _ in }
|
||||
public var dismissClipboardSuggestion: () -> Void = {}
|
||||
public var clearClipboardHistory: () -> Void = {}
|
||||
public var deleteClipboardHistoryEntry: (UUID) -> Void = { _ in }
|
||||
@ObservationIgnored public var openClipboardPanel: () -> Void = {}
|
||||
@ObservationIgnored public var dismissClipboardOverlay: () -> Void = {}
|
||||
@ObservationIgnored public var insertClipboardText: (String) -> Void = { _ in }
|
||||
@ObservationIgnored public var dismissClipboardSuggestion: () -> Void = {}
|
||||
@ObservationIgnored public var clearClipboardHistory: () -> Void = {}
|
||||
@ObservationIgnored public var deleteClipboardHistoryEntry: (UUID) -> Void = { _ in }
|
||||
/// Notify that the user inserted text (hides suggestion strip).
|
||||
public var noteUserDidInputText: () -> Void = {}
|
||||
@ObservationIgnored public var noteUserDidInputText: () -> Void = {}
|
||||
/// System globe (🌐) key target. Kept weak to avoid a state → controller
|
||||
/// ownership cycle; UIKit's standard all-touch-events action provides both
|
||||
/// tap-to-advance and long-press input-mode selection.
|
||||
public weak var inputModeController: UIInputViewController?
|
||||
public var startFlowSession: () -> Void = {}
|
||||
public var setMode: (InputMode) -> Void = { _ in }
|
||||
public var setLocale: (String) -> Void = { _ in }
|
||||
public var setEngineMode: (String) -> Void = { _ in }
|
||||
@ObservationIgnored public weak var inputModeController: UIInputViewController?
|
||||
@ObservationIgnored public var startFlowSession: () -> Void = {}
|
||||
@ObservationIgnored public var setMode: (InputMode) -> Void = { _ in }
|
||||
@ObservationIgnored public var setLocale: (String) -> Void = { _ in }
|
||||
@ObservationIgnored public var setEngineMode: (String) -> Void = { _ in }
|
||||
/// Only the locale picker remains; `enabled`
|
||||
/// is derived from the locale id, so there's no separate toggle to
|
||||
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
||||
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
|
||||
public var insertNewline: () -> Void = {}
|
||||
public var insertSpace: () -> Void = {}
|
||||
public var deleteBackward: () -> Void = {}
|
||||
@ObservationIgnored public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
|
||||
@ObservationIgnored public var insertNewline: () -> Void = {}
|
||||
@ObservationIgnored public var insertSpace: () -> Void = {}
|
||||
@ObservationIgnored public var deleteBackward: () -> Void = {}
|
||||
/// Undo the last voice insertion when `undoAvailable` is true.
|
||||
public var undoLastInsertion: () -> Void = {}
|
||||
@ObservationIgnored public var undoLastInsertion: () -> Void = {}
|
||||
/// Redo the last undone voice insertion when `redoAvailable` is true.
|
||||
public var redoLastInsertion: () -> Void = {}
|
||||
@ObservationIgnored public var redoLastInsertion: () -> Void = {}
|
||||
/// Copy the current text selection to the pasteboard.
|
||||
public var copySelection: () -> Void = {}
|
||||
@ObservationIgnored public var copySelection: () -> Void = {}
|
||||
/// Cut the current text selection (copy + delete).
|
||||
public var cutSelection: () -> Void = {}
|
||||
@ObservationIgnored public var cutSelection: () -> Void = {}
|
||||
/// Switch voice ↔ typing. No-ops when voice pipeline is active.
|
||||
public var setSurface: (Surface) -> Void = { _ in }
|
||||
@ObservationIgnored public var setSurface: (Surface) -> Void = { _ in }
|
||||
|
||||
/// Recording / processing must stay on the voice surface.
|
||||
public var locksTypingSurface: Bool {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public enum Keychain: @unchecked Sendable {
|
||||
public enum Keychain: Sendable {
|
||||
|
||||
public enum KeychainError: Error, Sendable, Equatable {
|
||||
case unexpectedStatus(OSStatus)
|
||||
|
||||
@@ -418,6 +418,7 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
|
||||
public enum LLMClientFactory {
|
||||
/// Build a client from the current `ProviderConfig`.
|
||||
@MainActor
|
||||
public static func make(from config: ProviderConfig) -> LLMClient {
|
||||
make(
|
||||
providerId: config.providerId,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// PersonalStyleLearningState.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Device-local memory that lets a personal style be relearned incrementally:
|
||||
// the compacted evidence of the last saved generation and the corpus it has
|
||||
// already absorbed. Dictation history keeps only a rolling window, so this is
|
||||
// the only place older learning survives. It is never synced — a device
|
||||
// without a matching record simply relearns from scratch.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Compacted stage-one evidence carried into the next generation.
|
||||
public struct PolishStyleLearningPriorEvidence: Codable, Equatable, Sendable {
|
||||
public struct Domain: Codable, Equatable, Sendable {
|
||||
public let traits: [PolishStyleLearningEvidence.Trait]
|
||||
public let contradictions: [PolishStyleLearningEvidence.Contradiction]
|
||||
}
|
||||
|
||||
public let asr: Domain
|
||||
public let reply: Domain
|
||||
/// Newest learned example timestamp. Only later examples count as new.
|
||||
public let learnedThrough: Date
|
||||
public let cumulativeASRExampleCount: Int
|
||||
public let cumulativeASREffectiveCharacterCount: Int
|
||||
public let cumulativeReplyExampleCount: Int
|
||||
public let cumulativeReplyFinalEditCount: Int
|
||||
}
|
||||
|
||||
/// A reviewed pack plus the evidence to persist once the user saves it.
|
||||
public struct PolishStyleLearningResult: Sendable {
|
||||
public let pack: PolishStylePack
|
||||
/// Nil when the generator produced no reusable evidence (fixtures).
|
||||
public let prior: PolishStyleLearningPriorEvidence?
|
||||
|
||||
public init(pack: PolishStylePack, prior: PolishStyleLearningPriorEvidence?) {
|
||||
self.pack = pack
|
||||
self.prior = prior
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class PersonalStyleLearningStateStore {
|
||||
public static let shared = PersonalStyleLearningStateStore()
|
||||
|
||||
private struct Record: Codable {
|
||||
let packID: String
|
||||
let generatedAt: Date
|
||||
let prior: PolishStyleLearningPriorEvidence
|
||||
}
|
||||
|
||||
private static let storageKey = "personalStyle.learningState.v1"
|
||||
/// Cloud-merged metadata may round dates, so match within one second.
|
||||
private static let generatedAtTolerance: TimeInterval = 1
|
||||
|
||||
private let defaults: UserDefaults?
|
||||
|
||||
public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// Returns the prior only when it belongs to exactly this saved
|
||||
/// generation. A pack replaced elsewhere (another device, an older build)
|
||||
/// has a different `generatedAt` and falls back to full relearning.
|
||||
public func prior(for pack: PolishStylePack) -> PolishStyleLearningPriorEvidence? {
|
||||
guard let generatedAt = pack.learningMetadata?.generatedAt,
|
||||
let record = load(),
|
||||
record.packID == pack.id,
|
||||
abs(record.generatedAt.timeIntervalSince(generatedAt))
|
||||
<= Self.generatedAtTolerance else {
|
||||
return nil
|
||||
}
|
||||
return record.prior
|
||||
}
|
||||
|
||||
/// Only one personal style is learned at a time, so a save replaces any
|
||||
/// earlier record.
|
||||
public func save(_ prior: PolishStyleLearningPriorEvidence, for pack: PolishStylePack) {
|
||||
guard let defaults,
|
||||
let generatedAt = pack.learningMetadata?.generatedAt,
|
||||
let data = try? JSONEncoder().encode(
|
||||
Record(packID: pack.id, generatedAt: generatedAt, prior: prior)
|
||||
) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
defaults?.removeObject(forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
private func load() -> Record? {
|
||||
guard let data = defaults?.data(forKey: Self.storageKey) else { return nil }
|
||||
return try? JSONDecoder().decode(Record.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,9 @@ public struct PolishStyleLearningEvidence: Codable, Equatable, Sendable {
|
||||
case replyFinalEdit
|
||||
case replyCrossContextSelection
|
||||
case replyAcceptance
|
||||
/// A trait retained from the previous generation's accumulated
|
||||
/// evidence, which is no longer present as raw examples.
|
||||
case priorProfile
|
||||
}
|
||||
|
||||
public struct Trait: Codable, Equatable, Sendable {
|
||||
@@ -147,6 +150,20 @@ public struct PolishStyleLearningCorpus: Equatable, Sendable {
|
||||
effectiveCharacterCount
|
||||
>= PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
}
|
||||
|
||||
/// Examples recorded after `date`, i.e. not yet absorbed by a prior
|
||||
/// generation.
|
||||
public func examples(after date: Date) -> PolishStyleLearningCorpus {
|
||||
let newer = examples.filter { $0.createdAt > date }
|
||||
return PolishStyleLearningCorpus(
|
||||
examples: newer,
|
||||
effectiveCharacterCount: newer.reduce(into: 0) { count, example in
|
||||
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
|
||||
in: example.prePolishText
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum PolishStyleLearningCorpusBuilder {
|
||||
@@ -160,6 +177,10 @@ public enum PolishStyleLearningCorpusBuilder {
|
||||
/// still a real gate; the previous "0 / unlimited" bypass is gone.
|
||||
public static let testBuildEffectiveCharacterCount = 1_250
|
||||
|
||||
/// New effective characters required since the last saved generation
|
||||
/// before an incremental relearn is worth a request.
|
||||
public static let incrementalEffectiveCharacterCount = 500
|
||||
|
||||
/// Upper bound on effective characters included in a single
|
||||
/// training-corpus export. Even when the user has accumulated
|
||||
/// significantly more history than the unlock threshold, the
|
||||
@@ -459,8 +480,16 @@ public actor PolishStyleLearningService {
|
||||
let examples: [ReplyExamplePayload]
|
||||
}
|
||||
|
||||
private struct PriorInput: Codable {
|
||||
let learnedThrough: Date
|
||||
let asr: PolishStyleLearningPriorEvidence.Domain
|
||||
let reply: PolishStyleLearningPriorEvidence.Domain
|
||||
}
|
||||
|
||||
private struct EvidenceRequestPayload: Codable {
|
||||
let schemaVersion: Int
|
||||
/// Omitted from the JSON entirely on a full (non-incremental) run.
|
||||
let prior: PriorInput?
|
||||
let asr: ASRInput
|
||||
let reply: ReplyInput
|
||||
}
|
||||
@@ -488,7 +517,16 @@ public actor PolishStyleLearningService {
|
||||
private static let maximumEvidenceItemsPerDomain = 24
|
||||
private static let maximumContradictionsPerDomain = 12
|
||||
private static let maximumEvidenceFieldCharacters = 320
|
||||
private static let learningSchemaVersion = 3
|
||||
private static let learningSchemaVersion = 4
|
||||
/// Each relearn without fresh support shrinks a prior trait; below the
|
||||
/// floor it is no longer sent, so unsupported traits fade out in a few
|
||||
/// rounds without per-trait bookkeeping.
|
||||
static let priorConfidenceDecay = 0.8
|
||||
static let priorRetentionFloor = 0.15
|
||||
static let maximumPriorTraitsPerDomain = 8
|
||||
static let maximumPriorContradictionsPerDomain = 6
|
||||
/// Keeps the prior well inside the 30,000-character request budget.
|
||||
static let maximumPriorCharacters = 4_000
|
||||
private static let generationOptions = LLMGenerationOptions(
|
||||
temperature: 0.1,
|
||||
topP: 0.9,
|
||||
@@ -513,7 +551,33 @@ public actor PolishStyleLearningService {
|
||||
minimumEffectiveCharacterCount: Int =
|
||||
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
) async throws -> PolishStylePack {
|
||||
let requiredCharacterCount = max(0, minimumEffectiveCharacterCount)
|
||||
try await learnStyle(
|
||||
from: corpus,
|
||||
replyExamples: replyExamples,
|
||||
outputLanguage: outputLanguage,
|
||||
minimumEffectiveCharacterCount: minimumEffectiveCharacterCount
|
||||
).pack
|
||||
}
|
||||
|
||||
/// With a `prior`, learns incrementally: only examples recorded after
|
||||
/// `prior.learnedThrough` are sent, merged with the decayed prior, and
|
||||
/// the incremental gate replaces `minimumEffectiveCharacterCount`.
|
||||
public func learnStyle(
|
||||
from fullCorpus: PolishStyleLearningCorpus,
|
||||
replyExamples allReplyExamples: [PolishStyleReplyLearningExample] = [],
|
||||
outputLanguage: AppUILanguage,
|
||||
minimumEffectiveCharacterCount: Int =
|
||||
PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount,
|
||||
prior: PolishStyleLearningPriorEvidence? = nil
|
||||
) async throws -> PolishStyleLearningResult {
|
||||
let corpus = prior.map { fullCorpus.examples(after: $0.learnedThrough) }
|
||||
?? fullCorpus
|
||||
let replyExamples = prior.map { prior in
|
||||
allReplyExamples.filter { $0.createdAt > prior.learnedThrough }
|
||||
} ?? allReplyExamples
|
||||
let requiredCharacterCount = prior == nil
|
||||
? max(0, minimumEffectiveCharacterCount)
|
||||
: PolishStyleLearningCorpusBuilder.incrementalEffectiveCharacterCount
|
||||
let verifiedCharacterCount = corpus.examples.reduce(into: 0) { count, example in
|
||||
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
|
||||
in: example.prePolishText
|
||||
@@ -540,7 +604,8 @@ public actor PolishStyleLearningService {
|
||||
replyExamples: selectedReplyExamples,
|
||||
activeStyleID: configuration.activePolishStyleId,
|
||||
catalog: configuration.polishStyleCatalog,
|
||||
outputLanguage: outputLanguage
|
||||
outputLanguage: outputLanguage,
|
||||
prior: prior.map(Self.decayedPrior)
|
||||
)
|
||||
let service = PolishingService(
|
||||
store: configuration,
|
||||
@@ -554,21 +619,30 @@ public actor PolishStyleLearningService {
|
||||
requiresBestEffortASRCandidate: !selectedASRExamples.isEmpty,
|
||||
notifiesManagedCredits: notifiesManagedCredits
|
||||
)
|
||||
// Counts are cumulative across incremental generations so the card
|
||||
// reflects everything this style has learned, not just this round.
|
||||
let asrExampleCount = (prior?.cumulativeASRExampleCount ?? 0)
|
||||
+ selectedASRExamples.count
|
||||
let asrEffectiveCharacterCount = (prior?.cumulativeASREffectiveCharacterCount ?? 0)
|
||||
+ selectedASRExamples.reduce(into: 0) { count, example in
|
||||
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
|
||||
in: example.prePolishText
|
||||
)
|
||||
}
|
||||
let replyExampleCount = (prior?.cumulativeReplyExampleCount ?? 0)
|
||||
+ selectedReplyExamples.count
|
||||
let replyFinalEditCount = (prior?.cumulativeReplyFinalEditCount ?? 0)
|
||||
+ selectedReplyExamples.filter { Self.normalized($0.finalEdit) != nil }.count
|
||||
let generatedAt = Date()
|
||||
let metadata = PolishStylePack.LearningMetadata(
|
||||
schemaVersion: Self.learningSchemaVersion,
|
||||
evidenceStatus: evidence.status.rawValue,
|
||||
confidence: evidence.confidence,
|
||||
asrExampleCount: selectedASRExamples.count,
|
||||
asrEffectiveCharacterCount: selectedASRExamples.reduce(into: 0) { count, example in
|
||||
count += PolishStyleLearningCorpusBuilder.effectiveCharacterCount(
|
||||
in: example.prePolishText
|
||||
)
|
||||
},
|
||||
replyExampleCount: selectedReplyExamples.count,
|
||||
replyFinalEditCount: selectedReplyExamples.filter {
|
||||
Self.normalized($0.finalEdit) != nil
|
||||
}.count,
|
||||
generatedAt: Date()
|
||||
asrExampleCount: asrExampleCount,
|
||||
asrEffectiveCharacterCount: asrEffectiveCharacterCount,
|
||||
replyExampleCount: replyExampleCount,
|
||||
replyFinalEditCount: replyFinalEditCount,
|
||||
generatedAt: generatedAt
|
||||
)
|
||||
// Always synthesize from this operation's evidence. Low-confidence
|
||||
// profiles use their strongest candidate traits; only genuinely empty
|
||||
@@ -577,13 +651,30 @@ public actor PolishStyleLearningService {
|
||||
evidence: evidence,
|
||||
metadata: metadata
|
||||
)
|
||||
return try await synthesizeStyle(
|
||||
let pack = try await synthesizeStyle(
|
||||
payload: synthesisPayload,
|
||||
service: service,
|
||||
learningMetadata: metadata,
|
||||
outputLanguage: outputLanguage,
|
||||
notifiesManagedCredits: notifiesManagedCredits
|
||||
)
|
||||
let newestLearnedExample = (
|
||||
selectedASRExamples.map(\.createdAt) + selectedReplyExamples.map(\.createdAt)
|
||||
).max()
|
||||
let learnedThrough = [newestLearnedExample, prior?.learnedThrough]
|
||||
.compactMap { $0 }
|
||||
.max() ?? generatedAt
|
||||
return PolishStyleLearningResult(
|
||||
pack: pack,
|
||||
prior: Self.compactPrior(
|
||||
from: evidence,
|
||||
learnedThrough: learnedThrough,
|
||||
cumulativeASRExampleCount: asrExampleCount,
|
||||
cumulativeASREffectiveCharacterCount: asrEffectiveCharacterCount,
|
||||
cumulativeReplyExampleCount: replyExampleCount,
|
||||
cumulativeReplyFinalEditCount: replyFinalEditCount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractEvidence(
|
||||
@@ -687,7 +778,8 @@ public actor PolishStyleLearningService {
|
||||
replyExamples: [PolishStyleReplyLearningExample],
|
||||
activeStyleID: String,
|
||||
catalog: PolishStyleCatalog,
|
||||
outputLanguage: AppUILanguage
|
||||
outputLanguage: AppUILanguage,
|
||||
prior: PolishStyleLearningPriorEvidence? = nil
|
||||
) throws -> String {
|
||||
let activeStyle = PolishStylePackCatalog.resolve(
|
||||
id: activeStyleID,
|
||||
@@ -706,6 +798,9 @@ public actor PolishStyleLearningService {
|
||||
)
|
||||
let payload = EvidenceRequestPayload(
|
||||
schemaVersion: learningSchemaVersion,
|
||||
prior: prior.map {
|
||||
PriorInput(learnedThrough: $0.learnedThrough, asr: $0.asr, reply: $0.reply)
|
||||
},
|
||||
asr: ASRInput(
|
||||
residualBaseline: reference(
|
||||
for: baselineStyle,
|
||||
@@ -896,9 +991,33 @@ public actor PolishStyleLearningService {
|
||||
The ASR domain may be empty only when asr.examples itself is empty.
|
||||
- Set status to "sufficient" only when total confidence is at least 0.5.
|
||||
|
||||
INCREMENTAL MERGE (only when the payload contains prior):
|
||||
- prior is this user's previously learned profile, already decayed for
|
||||
age. It summarizes older evidence that is no longer in the payload.
|
||||
Unlike the contamination style prompts, it is evidence about the user.
|
||||
asr.examples and reply.examples contain only newer observations.
|
||||
- Return one merged profile covering prior and the newer examples.
|
||||
- Keep every prior trait the newer examples do not contradict, at its
|
||||
given confidence or lower. Never raise a prior trait without new
|
||||
support.
|
||||
- When newer examples support a prior trait, keep its name, add the new
|
||||
support to its supportCount, and you may raise its confidence.
|
||||
- When newer examples contradict a prior trait, lower its confidence and
|
||||
add a contradictions entry; drop it only when the contradiction is
|
||||
clear and repeated. Keep prior contradictions unless resolved.
|
||||
- Cite retained prior traits with source priorProfile and the prior
|
||||
supportCount. priorProfile ranks with asrRepeatedBefore and
|
||||
replyCrossContextSelection: after user edits, before single
|
||||
observations.
|
||||
- Keep prior ASR and reply traits in their own domains.
|
||||
- When over the trait limit, drop the lowest-confidence prior traits.
|
||||
- status and confidence describe the merged profile. "insufficient"
|
||||
requires overall and every trait confidence at most 0.35;
|
||||
"sufficient" requires overall confidence at least 0.5.
|
||||
|
||||
Allowed source values:
|
||||
asrUserEdit, asrRepeatedBefore, asrObservedBefore, replyFinalEdit,
|
||||
replyCrossContextSelection, replyAcceptance.
|
||||
replyCrossContextSelection, replyAcceptance, priorProfile.
|
||||
|
||||
Return this exact Codable shape:
|
||||
{
|
||||
@@ -1096,6 +1215,118 @@ public actor PolishStyleLearningService {
|
||||
return references
|
||||
}
|
||||
|
||||
// MARK: - Incremental prior
|
||||
|
||||
/// Ages the prior before it is sent: every trait loses confidence, and
|
||||
/// traits that fall below the retention floor are dropped. Decay is local
|
||||
/// and deterministic rather than delegated to the model.
|
||||
static func decayedPrior(
|
||||
_ prior: PolishStyleLearningPriorEvidence
|
||||
) -> PolishStyleLearningPriorEvidence {
|
||||
func decayed(
|
||||
_ domain: PolishStyleLearningPriorEvidence.Domain
|
||||
) -> PolishStyleLearningPriorEvidence.Domain {
|
||||
PolishStyleLearningPriorEvidence.Domain(
|
||||
traits: domain.traits.compactMap { trait in
|
||||
let confidence = trait.confidence * priorConfidenceDecay
|
||||
guard confidence >= priorRetentionFloor else { return nil }
|
||||
return PolishStyleLearningEvidence.Trait(
|
||||
name: trait.name,
|
||||
description: trait.description,
|
||||
confidence: confidence,
|
||||
supportCount: trait.supportCount
|
||||
)
|
||||
},
|
||||
contradictions: domain.contradictions
|
||||
)
|
||||
}
|
||||
return PolishStyleLearningPriorEvidence(
|
||||
asr: decayed(prior.asr),
|
||||
reply: decayed(prior.reply),
|
||||
learnedThrough: prior.learnedThrough,
|
||||
cumulativeASRExampleCount: prior.cumulativeASRExampleCount,
|
||||
cumulativeASREffectiveCharacterCount: prior.cumulativeASREffectiveCharacterCount,
|
||||
cumulativeReplyExampleCount: prior.cumulativeReplyExampleCount,
|
||||
cumulativeReplyFinalEditCount: prior.cumulativeReplyFinalEditCount
|
||||
)
|
||||
}
|
||||
|
||||
/// Reduces validated evidence to what the next relearn needs: the
|
||||
/// strongest traits and a few contradictions per domain, within
|
||||
/// `maximumPriorCharacters`. Evidence items are not kept; the next run
|
||||
/// cites retained traits as `priorProfile`.
|
||||
static func compactPrior(
|
||||
from evidence: PolishStyleLearningEvidence,
|
||||
learnedThrough: Date,
|
||||
cumulativeASRExampleCount: Int,
|
||||
cumulativeASREffectiveCharacterCount: Int,
|
||||
cumulativeReplyExampleCount: Int,
|
||||
cumulativeReplyFinalEditCount: Int
|
||||
) -> PolishStyleLearningPriorEvidence {
|
||||
func strongest(
|
||||
_ traits: [PolishStyleLearningEvidence.Trait]
|
||||
) -> [PolishStyleLearningEvidence.Trait] {
|
||||
Array(
|
||||
traits
|
||||
.filter { $0.confidence >= priorRetentionFloor }
|
||||
.sorted { $0.confidence > $1.confidence }
|
||||
.prefix(maximumPriorTraitsPerDomain)
|
||||
)
|
||||
}
|
||||
|
||||
var asrTraits = strongest(evidence.asr.traits)
|
||||
var replyTraits = strongest(evidence.reply.traits)
|
||||
var asrContradictions = Array(
|
||||
evidence.asr.contradictions.prefix(maximumPriorContradictionsPerDomain)
|
||||
)
|
||||
var replyContradictions = Array(
|
||||
evidence.reply.contradictions.prefix(maximumPriorContradictionsPerDomain)
|
||||
)
|
||||
|
||||
func makePrior() -> PolishStyleLearningPriorEvidence {
|
||||
PolishStyleLearningPriorEvidence(
|
||||
asr: .init(traits: asrTraits, contradictions: asrContradictions),
|
||||
reply: .init(traits: replyTraits, contradictions: replyContradictions),
|
||||
learnedThrough: learnedThrough,
|
||||
cumulativeASRExampleCount: cumulativeASRExampleCount,
|
||||
cumulativeASREffectiveCharacterCount: cumulativeASREffectiveCharacterCount,
|
||||
cumulativeReplyExampleCount: cumulativeReplyExampleCount,
|
||||
cumulativeReplyFinalEditCount: cumulativeReplyFinalEditCount
|
||||
)
|
||||
}
|
||||
|
||||
var prior = makePrior()
|
||||
// Over budget: shed contradictions first, then the weakest trait
|
||||
// across both domains, until the prior fits.
|
||||
while priorCharacterCount(prior) > maximumPriorCharacters {
|
||||
if !replyContradictions.isEmpty {
|
||||
replyContradictions.removeLast()
|
||||
} else if !asrContradictions.isEmpty {
|
||||
asrContradictions.removeLast()
|
||||
} else if let weakestASR = asrTraits.last,
|
||||
weakestASR.confidence <= (replyTraits.last?.confidence ?? .infinity) {
|
||||
asrTraits.removeLast()
|
||||
} else if !replyTraits.isEmpty {
|
||||
replyTraits.removeLast()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
prior = makePrior()
|
||||
}
|
||||
return prior
|
||||
}
|
||||
|
||||
static func priorCharacterCount(_ prior: PolishStyleLearningPriorEvidence) -> Int {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
|
||||
let domains = [prior.asr, prior.reply]
|
||||
guard let data = try? encoder.encode(domains),
|
||||
let text = String(data: data, encoding: .utf8) else {
|
||||
return 0
|
||||
}
|
||||
return text.count
|
||||
}
|
||||
|
||||
private static func boundedExample(
|
||||
_ example: PolishStyleLearningExample
|
||||
) -> PolishStyleLearningExample {
|
||||
@@ -1303,7 +1534,8 @@ public actor PolishStyleLearningService {
|
||||
allowedSources: [
|
||||
.asrUserEdit,
|
||||
.asrRepeatedBefore,
|
||||
.asrObservedBefore
|
||||
.asrObservedBefore,
|
||||
.priorProfile
|
||||
]
|
||||
),
|
||||
isValid(
|
||||
@@ -1311,7 +1543,8 @@ public actor PolishStyleLearningService {
|
||||
allowedSources: [
|
||||
.replyFinalEdit,
|
||||
.replyCrossContextSelection,
|
||||
.replyAcceptance
|
||||
.replyAcceptance,
|
||||
.priorProfile
|
||||
]
|
||||
) else {
|
||||
return false
|
||||
@@ -1369,7 +1602,8 @@ public actor PolishStyleLearningService {
|
||||
switch item.source {
|
||||
case .asrRepeatedBefore, .replyCrossContextSelection:
|
||||
return item.supportCount >= 2
|
||||
case .asrUserEdit, .asrObservedBefore, .replyFinalEdit, .replyAcceptance:
|
||||
case .asrUserEdit, .asrObservedBefore, .replyFinalEdit, .replyAcceptance,
|
||||
.priorProfile:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1388,7 +1622,7 @@ public actor PolishStyleLearningService {
|
||||
switch source {
|
||||
case .asrUserEdit, .replyFinalEdit:
|
||||
return 0
|
||||
case .asrRepeatedBefore, .replyCrossContextSelection:
|
||||
case .asrRepeatedBefore, .replyCrossContextSelection, .priorProfile:
|
||||
return 1
|
||||
case .asrObservedBefore, .replyAcceptance:
|
||||
return 2
|
||||
|
||||
@@ -10,6 +10,10 @@ import Foundation
|
||||
public final class EnglishLearningStore: @unchecked Sendable {
|
||||
public static let defaultsKey = "englishTyping.learnedBoosts.v1"
|
||||
|
||||
/// Static because several instances share the same defaults key; a
|
||||
/// per-instance lock would not serialize their read-modify-write.
|
||||
private static let mutationLock = NSLock()
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroupStore().defaults) {
|
||||
@@ -42,8 +46,10 @@ public final class EnglishLearningStore: @unchecked Sendable {
|
||||
private func mutate(word: String, delta: Int) {
|
||||
let key = word.lowercased()
|
||||
guard !key.isEmpty else { return }
|
||||
var map = snapshot()
|
||||
map[key] = min(10_000, (map[key] ?? 0) + delta)
|
||||
defaults.set(map, forKey: Self.defaultsKey)
|
||||
Self.mutationLock.withLock {
|
||||
var map = snapshot()
|
||||
map[key] = min(10_000, (map[key] ?? 0) + delta)
|
||||
defaults.set(map, forKey: Self.defaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ public final class LibrimeEngine: RimeEngineBridging {
|
||||
let configured = configurationProvider()
|
||||
schema = configured.schema
|
||||
guard runtime.selectSchema(schema.rawValue) else {
|
||||
runtime.stopSession()
|
||||
// `start()` already initialized the shared runtime; finalize it so
|
||||
// no user dictionary stays open without an owning bridge.
|
||||
runtime.finalizeRuntime()
|
||||
throw LibrimeEngineError.schemaUnavailable(schema.rawValue)
|
||||
}
|
||||
_ = runtime.setASCIIMode(language == .english)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// VoiceKeyRowLayout.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Keyboard-style voice bottom row for wide hosts (iPad, unfolded iPhone Duo).
|
||||
|
||||
import CoreGraphics
|
||||
|
||||
/// Horizontal geometry of the wide-host voice bottom row:
|
||||
///
|
||||
/// [leadingSide][leadingContext][ send ][trailingContext][trailingSide]
|
||||
///
|
||||
/// - The four icon keys (space / undo / edit / delete) share one width and send
|
||||
/// is exactly twice as wide, so the primary action reads as the main key while
|
||||
/// the row fills edge to edge with uniform gaps. The row is symmetric, so send
|
||||
/// sits on the centre line under the microphone.
|
||||
/// - No globe: the voice surface leaves input-mode switching to the typing tab.
|
||||
/// - Context slots (undo / edit) exist even while the keys are disabled, so
|
||||
/// nothing in the row shifts when they toggle.
|
||||
///
|
||||
/// Coordinates are relative to the row's own leading edge.
|
||||
public struct VoiceKeyRowLayout: Equatable, Sendable {
|
||||
public struct Slot: Equatable, Sendable {
|
||||
public let minX: CGFloat
|
||||
public let width: CGFloat
|
||||
|
||||
public var midX: CGFloat { minX + width / 2 }
|
||||
public var maxX: CGFloat { minX + width }
|
||||
|
||||
public init(minX: CGFloat, width: CGFloat) {
|
||||
self.minX = minX
|
||||
self.width = width
|
||||
}
|
||||
}
|
||||
|
||||
/// Send width as a multiple of one icon key.
|
||||
public static let sendWidthMultiplier: CGFloat = 2
|
||||
/// Below this host width the icon keys would shrink past a comfortable
|
||||
/// touch target, so narrower hosts keep the phone's circle cluster.
|
||||
public static let minimumHostWidth: CGFloat = 500
|
||||
|
||||
public let leadingSide: Slot
|
||||
public let leadingContext: Slot
|
||||
public let send: Slot
|
||||
public let trailingContext: Slot
|
||||
public let trailingSide: Slot
|
||||
|
||||
/// Wide hosts only: iPad-scale metrics, the expanded-phone tier, or a
|
||||
/// landscape iPhone (whose shorter panel has no room for two circle rows),
|
||||
/// with enough width for the row. Portrait iPhones keep the circle
|
||||
/// cluster, which suits one-handed thumb reach.
|
||||
public static func isAvailable(
|
||||
usesIPadMetrics: Bool,
|
||||
usesExpandedPhoneMetrics: Bool,
|
||||
usesCompactHeightPhoneMetrics: Bool,
|
||||
hostWidth: CGFloat
|
||||
) -> Bool {
|
||||
(usesIPadMetrics || usesExpandedPhoneMetrics || usesCompactHeightPhoneMetrics)
|
||||
&& hostWidth >= minimumHostWidth
|
||||
}
|
||||
|
||||
/// - Parameter rowWidth: The row's width, i.e. the same width the typing bottom row spans.
|
||||
public static func make(rowWidth: CGFloat) -> VoiceKeyRowLayout {
|
||||
let spacing = KeyboardChromeLayout.actionKeySpacing
|
||||
|
||||
// 4 icon keys + one send key worth `sendWidthMultiplier` icon keys,
|
||||
// with 4 gaps between those five.
|
||||
let keySpan = max(0, rowWidth - spacing * 4)
|
||||
let keyWidth = keySpan / (4 + sendWidthMultiplier)
|
||||
|
||||
let leadingSide = Slot(minX: 0, width: keyWidth)
|
||||
let leadingContext = Slot(minX: leadingSide.maxX + spacing, width: keyWidth)
|
||||
let send = Slot(minX: leadingContext.maxX + spacing, width: keyWidth * sendWidthMultiplier)
|
||||
let trailingContext = Slot(minX: send.maxX + spacing, width: keyWidth)
|
||||
let trailingSide = Slot(minX: trailingContext.maxX + spacing, width: keyWidth)
|
||||
|
||||
return VoiceKeyRowLayout(
|
||||
leadingSide: leadingSide,
|
||||
leadingContext: leadingContext,
|
||||
send: send,
|
||||
trailingContext: trailingContext,
|
||||
trailingSide: trailingSide
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user