chore(release): bump version to 2.1.6 (build 131)
Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs, docs/llms.txt). fix(flow): host-app identification now compiles in every build configuration (Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and could not auto-return after a startflow handoff. Debug-only logging and the Home test card are unchanged. Also includes the pending work in the tree: - Home: single account/setup card surface (HomeAccountSurfacePolicy/Views) - Wide screens: voice key row and centred mic placement, iPhone Duo layout - Keyboard: clipboard skill ranking and AI skill layout updates - Debug: account session handoff to the Simulator (+ import script) - Docs: portfolio assets, privacy policy URL, site/README refresh
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.5 (build 125)**. Releases use stable SemVer:
|
||||
The current source-of-truth version is **2.1.6 (build 131)**. Releases use stable SemVer:
|
||||
|
||||
| Field | File | Rule |
|
||||
|-------|------|------|
|
||||
|
||||
+39
-12
@@ -7,14 +7,52 @@ 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
|
||||
- **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.0. / **产品页重设计**:GitHub Pages 主页改为深色产品舞台,启用 Icon Composer 新图标与三端同框主视觉,以实机视频与真实界面卡片取代手绘示意;补齐 2.1 卖点(专属风格、三语气个性回复、月度用量日历、EventKit 技能写入、引导体验与积分奖励),新增 TestFlight 入口,图标改为自托管内联 SVG;页面版本号更新至 2.1.0。
|
||||
- **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 次)只刷新波形条,实时识别文字只刷新状态行,不再每次都重建整个语音面板。
|
||||
|
||||
@@ -35,17 +73,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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 或跟随系统语言。
|
||||
- **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 字即可重新生成;卡片显示累计学习字数与自上次生成后的新增进度。长期没有新证据支持的特征会在几轮后自然淘汰。没有本机学习记录的风格(旧版本或其他设备生成)会全量重新学习并替换。
|
||||
|
||||
## [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
|
||||
- **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. / **异常音频线路**:重采样帧数在转换前先行钳制,采样率接近零时仅丢弃一帧,而不会在实时音频线程上触发陷阱。
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,61 +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)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
ReferralCodeEntryRow()
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
signedInContent
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private var signedInContent: some View {
|
||||
@@ -117,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)
|
||||
|
||||
@@ -164,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()
|
||||
@@ -544,7 +548,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
private func expireSession(
|
||||
errorKey: String,
|
||||
errorKey: String?,
|
||||
signsOutService: Bool
|
||||
) async {
|
||||
guard isSignedIn else { return }
|
||||
@@ -572,6 +576,48 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,10 @@ 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
|
||||
}
|
||||
|
||||
@State private var state = KeyboardState()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +421,7 @@ struct ClipboardHistoryDemoView: View {
|
||||
state.showsSystemGlobeKey = false
|
||||
// The skill ranker localizes chip titles off this.
|
||||
state.uiLanguage = Self.isEnglish ? .english : .chinese
|
||||
// `AIKeyboardView.showsClipboardSkills` gates on this — without it the
|
||||
// `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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,14 @@ struct PersonalReplyStyleSection: View {
|
||||
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, prior in
|
||||
@@ -92,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()
|
||||
}
|
||||
@@ -111,7 +127,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -268,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() {
|
||||
@@ -281,6 +282,7 @@ struct HomeDictionaryScreenshotHarness: View {
|
||||
ThemedRoot {
|
||||
HomeView()
|
||||
.environmentObject(flowManager)
|
||||
.environmentObject(accountSession)
|
||||
}
|
||||
.environment(\.locale, language.swiftUILocale)
|
||||
.preferredColorScheme(.light)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "委婉拒绝";
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,7 +179,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
#if DEBUG
|
||||
HostBundleProbe.installLogging()
|
||||
#endif
|
||||
state.showsSystemGlobeKey = UIDevice.current.userInterfaceIdiom == .pad
|
||||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||||
primaryLanguage = "mis"
|
||||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||||
@@ -359,7 +379,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
|
||||
}
|
||||
#if DEBUG
|
||||
HostBundleProbe.recordPresentationAndPing()
|
||||
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,
|
||||
@@ -404,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()
|
||||
@@ -949,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"
|
||||
@@ -1209,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")
|
||||
|
||||
@@ -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,12 +27,32 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。
|
||||
// 目前依赖仅在 DEBUG 编译的私有 arbiter 探针(`OSGHostArbiterProbe`),
|
||||
// Release 构建里识别结果恒为 nil,行为与接入前一致。
|
||||
// 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效;
|
||||
// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”。
|
||||
// 调试日志与首页测试卡片仍只在 DEBUG 编译。
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
@@ -11,15 +12,16 @@ import OSGKeyboardShared
|
||||
enum HostAppIdentity {
|
||||
/// 最近一次识别到的宿主;识别不到、或宿主是 OSG 自己时为 nil。
|
||||
static var currentBundleId: String? {
|
||||
#if DEBUG
|
||||
guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId,
|
||||
!HostAppURLRegistry.isOwnBundleId(bundleId) else {
|
||||
return nil
|
||||
}
|
||||
return bundleId
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 键盘弹出时主动让 arbiter 回调一次,避免宿主没变时拿不到最新 ID。
|
||||
static func refreshOnPresentation() {
|
||||
OSGHostArbiterProbe.pingArbiter()
|
||||
}
|
||||
|
||||
/// 跳转主 App(`startflow`)前调用:把当前宿主写进 App Group,主 App 就绪后据此跳回。
|
||||
@@ -39,14 +41,12 @@ enum HostAppIdentity {
|
||||
|
||||
/// 键盘收起时作废识别结果,下次弹出由新回调重新填充。
|
||||
static func invalidate() {
|
||||
#if DEBUG
|
||||
OSGHostArbiterProbe.invalidateCapturedBundleId()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 临时调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。
|
||||
/// 调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。
|
||||
enum HostBundleProbe {
|
||||
/// 只需安装一次;回调可能来自非主线程,闭包必须是 nonisolated。
|
||||
static func installLogging() {
|
||||
@@ -55,14 +55,13 @@ enum HostBundleProbe {
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘弹出时记一条标记,再主动触发回调,便于把记录和每次弹出对应起来。
|
||||
static func recordPresentationAndPing() {
|
||||
/// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。
|
||||
static func recordPresentation() {
|
||||
HostBundleProbeLog.append(
|
||||
event: "appear",
|
||||
bundleId: nil,
|
||||
detail: OSGHostArbiterProbe.isInstalled ? "hook=installed" : "hook=missing"
|
||||
)
|
||||
OSGHostArbiterProbe.pingArbiter()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// OSGHostArbiterProbe.h
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 临时调试探针(仅 DEBUG):iOS 26.4+ 通过 _UIKeyboardArbiterClient 回调
|
||||
// 读取宿主 App 的 bundle ID。原理参考 editorss/KeyboardHostBundleID(MIT)。
|
||||
// 宿主识别探针(Debug / TestFlight / Release 均编译):iOS 26.4+ 通过
|
||||
// _UIKeyboardArbiterClient 回调读取宿主 App 的 bundle ID,供冷启动后自动跳回。
|
||||
// 原理参考 editorss/KeyboardHostBundleID(MIT)。
|
||||
// 必须在 +load 安装,否则可能错过键盘首次切换宿主时的回调。
|
||||
// 所有私有符号运行时按字符串查找,任何一步失败都静默退化为“识别不到宿主”。
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
#ifdef DEBUG
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 回调可能在 arbiter 的私有队列上触发,不保证主线程。
|
||||
@@ -34,5 +34,3 @@ typedef void (^OSGHostArbiterCaptureHandler)(NSString *_Nullable bundleId, NSStr
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// OSGHostArbiterProbe.m
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 临时调试探针(仅 DEBUG),见头文件说明。所有私有符号都在运行时按字符串查找,
|
||||
// 宿主识别探针(所有构建配置均编译),见头文件说明。所有私有符号都在运行时按字符串查找,
|
||||
// 任何一步找不到都静默放弃,并保证原始实现照常执行。
|
||||
|
||||
#import "OSGHostArbiterProbe.h"
|
||||
|
||||
#ifdef DEBUG
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <objc/message.h>
|
||||
#import <objc/runtime.h>
|
||||
@@ -129,5 +127,3 @@ static BOOL OSGAlwaysEnabled(__unused id self, __unused SEL _cmd) {
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
|
||||
@@ -22,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
|
||||
@@ -30,12 +32,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -52,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?)?
|
||||
@@ -77,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
|
||||
@@ -124,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
|
||||
@@ -152,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
|
||||
@@ -178,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)
|
||||
}
|
||||
@@ -233,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)
|
||||
}
|
||||
@@ -288,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)
|
||||
}
|
||||
@@ -348,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
|
||||
@@ -375,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(
|
||||
@@ -405,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(
|
||||
@@ -470,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),
|
||||
@@ -486,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)
|
||||
@@ -534,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).
|
||||
@@ -572,7 +617,8 @@ struct AIKeyboardView: View {
|
||||
sourceText: newest.text,
|
||||
analysis: snapshot.analysis,
|
||||
uiLanguage: state.uiLanguage,
|
||||
limit: 6
|
||||
limit: 6,
|
||||
translationTargetLocaleId: state.translationTargetLocaleId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -604,9 +650,7 @@ struct AIKeyboardView: View {
|
||||
@ViewBuilder
|
||||
private var contextArea: some View {
|
||||
ZStack {
|
||||
if showsOOBEClipboardSkill, !hasActiveStatus {
|
||||
clipboardSkillPager
|
||||
} else if let onboardingPracticeHint, !hasActiveStatus {
|
||||
if let onboardingPracticeHint, !hasActiveStatus {
|
||||
Text(onboardingPracticeHint)
|
||||
.font(TypeStyle.footnote.weight(.semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -627,13 +671,6 @@ 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 hasActiveStatus {
|
||||
@@ -848,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,
|
||||
@@ -937,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
|
||||
}
|
||||
@@ -1018,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
|
||||
}
|
||||
@@ -1035,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"),
|
||||
@@ -1225,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
|
||||
}
|
||||
@@ -1313,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"),
|
||||
@@ -1422,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(
|
||||
@@ -1532,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -8,6 +8,25 @@ 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
|
||||
@@ -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" = "确认任务";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -64,7 +64,7 @@ struct MacRootView: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -237,13 +237,30 @@ public final class KeyboardState {
|
||||
/// The view controller resolves this from device idiom + horizontal size
|
||||
/// class so SwiftUI and the UIKit height constraint cannot disagree.
|
||||
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.
|
||||
/// 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.
|
||||
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`).
|
||||
public var undoAvailable: Bool = false
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// VoiceMicPlacement.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Vertical placement of the voice surface's mic row. Every shell height
|
||||
// (phone, unfolded Duo, landscape, iPad narrow / wide) leaves a different
|
||||
// amount of free height between the top bar and the bottom row; this splits it
|
||||
// into the context slot above the mic and the gap below it with one rule, so
|
||||
// the mic sits centred in the area below the top bar on every device.
|
||||
|
||||
import CoreGraphics
|
||||
|
||||
public enum VoiceMicPlacement {
|
||||
/// Never let the mic touch the bottom row.
|
||||
public static let minimumGap: CGFloat = 10
|
||||
|
||||
/// Context slot height for a free height (`contextAndGap` = slot + gap).
|
||||
/// Centred means the slot above the mic equals the gap plus the bottom row
|
||||
/// below it, but the slot never drops under `minimumContextHeight` (the
|
||||
/// live transcript) and the gap never under `minimumGap`.
|
||||
public static func contextHeight(
|
||||
contextAndGap: CGFloat,
|
||||
bottomRowHeight: CGFloat,
|
||||
minimumContextHeight: CGFloat
|
||||
) -> CGFloat {
|
||||
let centred = (contextAndGap + bottomRowHeight) / 2
|
||||
let ceiling = max(0, contextAndGap - minimumGap)
|
||||
return min(ceiling, max(minimumContextHeight, centred))
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
@@ -173,6 +175,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
@@ -205,6 +209,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
@@ -240,6 +246,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
@@ -247,7 +255,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
11
|
||||
12
|
||||
)
|
||||
}
|
||||
|
||||
@@ -272,6 +280,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
@@ -306,12 +316,19 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
let migrated = AppGroupStore(defaults: defaults).agentSkillLayout
|
||||
|
||||
XCTAssertEqual(migrated.enabledIDs, [AIClipboardSkillCatalog.replyID])
|
||||
XCTAssertEqual(
|
||||
migrated.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
11
|
||||
12
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,16 +349,27 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
|
||||
XCTAssertEqual(store.agentSkillLayout.enabledIDs, [AIClipboardSkillCatalog.translateID])
|
||||
XCTAssertEqual(
|
||||
store.agentSkillLayout.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
11
|
||||
12
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AppGroupStore(defaults: defaults).agentSkillLayout.enabledIDs,
|
||||
[AIClipboardSkillCatalog.translateID]
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -367,13 +395,18 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(
|
||||
migrated.enabledIDs,
|
||||
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
11
|
||||
12
|
||||
)
|
||||
}
|
||||
|
||||
@@ -400,7 +433,11 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(
|
||||
AppGroupStore(defaults: enabledDefaults).agentSkillLayout.enabledIDs,
|
||||
[AIClipboardSkillCatalog.replyID]
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
|
||||
let disabledDefaults = makeDefaults()
|
||||
@@ -419,7 +456,11 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AppGroupStore(defaults: disabledDefaults).agentSkillLayout.enabledIDs,
|
||||
[AIClipboardSkillCatalog.translateID]
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,64 @@ final class AIClipboardSkillTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testTranslateToTargetUsesConfiguredTargetLanguage() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.translateToTargetID,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: "ja",
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("Japanese"))
|
||||
XCTAssertFalse(prompt.contains("Chinese"))
|
||||
}
|
||||
|
||||
func testTranslateToTargetPromptFallsBackWhenTargetIsOff() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.translateToTargetID,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("English"))
|
||||
}
|
||||
|
||||
func testTranslateToTargetButtonTitleNamesTargetAndHidesWhenOff() {
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: "en",
|
||||
uiLanguage: .chinese
|
||||
),
|
||||
"翻译英语"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: "ja",
|
||||
uiLanguage: .english
|
||||
),
|
||||
"To Japanese"
|
||||
)
|
||||
XCTAssertNil(
|
||||
AIClipboardSkillCatalog.translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
uiLanguage: .english
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPolishKeepsOriginalLanguageAndCarriesNeverAnswerBoundary() throws {
|
||||
let skill = try XCTUnwrap(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.polishOriginalID)
|
||||
)
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
for: skill,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: "ja"
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("without changing its language"))
|
||||
XCTAssertTrue(prompt.contains("grammar"))
|
||||
XCTAssertTrue(prompt.contains("Non-negotiable boundary"))
|
||||
XCTAssertFalse(prompt.contains("Japanese"))
|
||||
}
|
||||
|
||||
func testSummarizeAsksForOverviewNotShortening() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.summarizeID,
|
||||
|
||||
@@ -133,6 +133,60 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertLessThan(try XCTUnwrap(translateIndex), try XCTUnwrap(speakAsMeIndex))
|
||||
}
|
||||
|
||||
/// Device-language text + a configured target → "Translate to <target>",
|
||||
/// and never the foreign-text actions (system translate / polish).
|
||||
func testSystemLanguageTextOffersTranslateToTarget() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "明天下午三点开会,别迟到。",
|
||||
analysis: analysis(language: "zh"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"],
|
||||
translationTargetLocaleId: "en"
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertTrue(recommendations.contains(AIClipboardSkillCatalog.translateToTargetID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.polishOriginalID))
|
||||
}
|
||||
|
||||
func testTranslateToTargetIsHiddenWhenTranslationIsOffOrTargetMatchesText() {
|
||||
for target in [TranslationLanguageCatalog.offLocaleId, "zh-Hans"] {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "明天下午三点开会,别迟到。",
|
||||
analysis: analysis(language: "zh"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"],
|
||||
translationTargetLocaleId: target
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertFalse(
|
||||
recommendations.contains(AIClipboardSkillCatalog.translateToTargetID),
|
||||
"target \(target)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreign text: system translate + polish, never translate-to-target.
|
||||
func testForeignTextOffersPolishButNotTranslateToTarget() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "I has a meeting tomorrow, please dont be late.",
|
||||
analysis: analysis(language: "en"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"],
|
||||
translationTargetLocaleId: "ja"
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertTrue(recommendations.contains(AIClipboardSkillCatalog.translateID))
|
||||
XCTAssertTrue(recommendations.contains(AIClipboardSkillCatalog.polishOriginalID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateToTargetID))
|
||||
}
|
||||
|
||||
func testTranslationIsNotRecommendedForSystemLanguage() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
@@ -161,6 +215,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
@@ -523,7 +578,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations.map(\.id),
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// DebugAccountSessionHandoffTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// DEBUG-only device → Simulator session handoff: the device must forget the
|
||||
// session it hands off, and the Simulator must never keep the handoff file.
|
||||
|
||||
#if DEBUG
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardHostSupport
|
||||
import XCTest
|
||||
|
||||
final class DebugAccountSessionHandoffTests: XCTestCase {
|
||||
private var directory: URL!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
|
||||
// MARK: - AccountAPIClient
|
||||
|
||||
func testRelinquishReturnsSessionAndForgetsItEverywhere() async throws {
|
||||
let session = makeAccountSession()
|
||||
let store = InMemoryAccountSecurityStore(session: session)
|
||||
let client = makeClient(store: store)
|
||||
// Warm the in-memory cache: clearing Keychain alone would not stop it.
|
||||
_ = try await client.currentSession()
|
||||
|
||||
let relinquished = try await client.debugRelinquishSession()
|
||||
|
||||
XCTAssertEqual(relinquished, session)
|
||||
let stored = await store.session
|
||||
XCTAssertNil(stored)
|
||||
let current = try await client.currentSession()
|
||||
XCTAssertNil(current)
|
||||
}
|
||||
|
||||
func testRelinquishWithoutSessionThrows() async {
|
||||
let client = makeClient(store: InMemoryAccountSecurityStore())
|
||||
|
||||
do {
|
||||
_ = try await client.debugRelinquishSession()
|
||||
XCTFail("Expected sessionUnavailable")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? AccountAPIError, .sessionUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func testAdoptReplacesCachedSession() async throws {
|
||||
let store = InMemoryAccountSecurityStore(session: makeAccountSession())
|
||||
let client = makeClient(store: store)
|
||||
_ = try await client.currentSession()
|
||||
let handedOff = makeAccountSession(accessToken: "access-device", refreshToken: "refresh-device")
|
||||
|
||||
try await client.debugAdoptSession(handedOff)
|
||||
|
||||
let current = try await client.currentSession()
|
||||
XCTAssertEqual(current, handedOff)
|
||||
let stored = await store.session
|
||||
XCTAssertEqual(stored, handedOff)
|
||||
}
|
||||
|
||||
// MARK: - Handoff file
|
||||
|
||||
func testExportFileRoundTripsThroughImportAndIsDeleted() throws {
|
||||
let session = makeAccountSession()
|
||||
let url = try DebugAccountSessionHandoff.writeExportFile(session, directory: directory)
|
||||
|
||||
let imported = DebugAccountSessionHandoff.consumePendingImport(directory: directory)
|
||||
|
||||
XCTAssertEqual(imported, session)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
func testMalformedImportIsRejectedAndStillDeleted() throws {
|
||||
let url = directory.appendingPathComponent(DebugAccountSessionHandoff.fileName)
|
||||
try Data(#"{"format":"something-else","session":{}}"#.utf8).write(to: url)
|
||||
|
||||
XCTAssertNil(DebugAccountSessionHandoff.consumePendingImport(directory: directory))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
func testParkedSessionIsPendingUntilConsumed() throws {
|
||||
XCTAssertFalse(DebugAccountSessionHandoff.hasPendingImport(directory: directory))
|
||||
_ = try DebugAccountSessionHandoff.writeExportFile(makeAccountSession(), directory: directory)
|
||||
|
||||
XCTAssertTrue(DebugAccountSessionHandoff.hasPendingImport(directory: directory))
|
||||
_ = DebugAccountSessionHandoff.consumePendingImport(directory: directory)
|
||||
XCTAssertFalse(DebugAccountSessionHandoff.hasPendingImport(directory: directory))
|
||||
}
|
||||
|
||||
func testMissingImportReturnsNil() {
|
||||
XCTAssertNil(DebugAccountSessionHandoff.consumePendingImport(directory: directory))
|
||||
}
|
||||
|
||||
func testImportedMarkerMatchesOnlyTheImportedAccount() throws {
|
||||
let suite = "DebugAccountSessionHandoffTests.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let imported = makeAccountSession().accountId
|
||||
|
||||
DebugAccountSessionHandoff.markImported(accountID: imported, defaults: defaults)
|
||||
|
||||
XCTAssertTrue(DebugAccountSessionHandoff.isImported(accountID: imported, defaults: defaults))
|
||||
XCTAssertFalse(DebugAccountSessionHandoff.isImported(accountID: UUID(), defaults: defaults))
|
||||
}
|
||||
|
||||
private func makeClient(store: InMemoryAccountSecurityStore) -> AccountAPIClient {
|
||||
AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: QueueAccountTransport([]),
|
||||
sessionVault: store
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -82,40 +82,6 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testConnectionCardDisappearsWhenSessionIsHealthy() {
|
||||
XCTAssertFalse(
|
||||
shouldShowConnectionCard(
|
||||
lifecycle: .active,
|
||||
isHostReady: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testConnectionCardRemainsVisibleWhilePreparingOrFailed() {
|
||||
XCTAssertTrue(
|
||||
shouldShowConnectionCard(
|
||||
lifecycle: .preparing(attempt: 1, total: 3),
|
||||
isHostReady: false
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
shouldShowConnectionCard(
|
||||
lifecycle: .failed(.timedOut),
|
||||
isHostReady: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalRecordingDoesNotBringConnectionCardBack() {
|
||||
XCTAssertFalse(
|
||||
shouldShowConnectionCard(
|
||||
lifecycle: .active,
|
||||
isRecording: true,
|
||||
isHostReady: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLocalAndPolishOnlySetupLinksToTextPolish() {
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
@@ -131,13 +97,6 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
),
|
||||
.textPolish
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: true
|
||||
),
|
||||
"home.setup.polishKeyMissing"
|
||||
)
|
||||
}
|
||||
|
||||
func testCloudSetupLinksToSpeechRecognitionWhenASRIsMissing() {
|
||||
@@ -148,13 +107,6 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
),
|
||||
.speechRecognition
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: false
|
||||
),
|
||||
"home.setup.cloudIncomplete"
|
||||
)
|
||||
}
|
||||
|
||||
private func descriptor(
|
||||
@@ -169,20 +121,4 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
isHostReady: false
|
||||
)
|
||||
}
|
||||
|
||||
private func shouldShowConnectionCard(
|
||||
lifecycle: FlowPiPLifecycleState,
|
||||
isRecording: Bool = false,
|
||||
isHostReady: Bool
|
||||
) -> Bool {
|
||||
FlowHomePiPStatusPolicy.shouldShowConnectionCard(
|
||||
lifecycle: lifecycle,
|
||||
needsPermissionSetup: false,
|
||||
needsAPIKeySetup: false,
|
||||
hasSessionWarning: false,
|
||||
isRecording: isRecording,
|
||||
isProcessing: false,
|
||||
isHostReady: isHostReady
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// HomeAccountSurfacePolicyTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
@testable import OSGKeyboard
|
||||
import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class HomeAccountSurfacePolicyTests: XCTestCase {
|
||||
|
||||
// MARK: - Readiness
|
||||
|
||||
func testCreditsNeedAnAccountButSurviveRestore() {
|
||||
XCTAssertEqual(readiness(.managed, account: .signedIn), .credits)
|
||||
XCTAssertEqual(readiness(.managed, account: .restoring), .credits)
|
||||
XCTAssertEqual(readiness(.managed, account: .signedOut), .unavailable)
|
||||
}
|
||||
|
||||
func testOwnKeyReadinessFollowsConfiguredKeys() {
|
||||
XCTAssertEqual(readiness(.byok, asr: true, polish: true), .ownKey)
|
||||
XCTAssertEqual(readiness(.byok, asr: true, polish: false), .dictationOnly)
|
||||
XCTAssertEqual(readiness(.byok, asr: false, polish: true), .unavailable)
|
||||
}
|
||||
|
||||
// MARK: - Surface rules, in priority order
|
||||
|
||||
func testMissingPermissionsOutrankEverything() {
|
||||
XCTAssertEqual(
|
||||
surface(needsPermissionSetup: true, account: .signedOut, pendingReferralCode: "K7M2Q9XA"),
|
||||
.permissions
|
||||
)
|
||||
}
|
||||
|
||||
func testRestoringShowsNoAccountSurface() {
|
||||
XCTAssertNil(surface(account: .restoring, readiness: .dictationOnly))
|
||||
}
|
||||
|
||||
func testSavedInvitationLeadsUntilDeferred() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedOut, pendingReferralCode: "K7M2Q9XA"),
|
||||
.invitation(code: "K7M2Q9XA")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
surface(
|
||||
account: .signedOut,
|
||||
pendingReferralCode: "K7M2Q9XA",
|
||||
isInvitationDeferred: true,
|
||||
readiness: .dictationOnly
|
||||
),
|
||||
.signInSetup
|
||||
)
|
||||
}
|
||||
|
||||
func testSignedOutWithoutWorkingServiceAsksToSignIn() {
|
||||
XCTAssertEqual(surface(account: .signedOut, readiness: .dictationOnly), .signInSetup)
|
||||
XCTAssertEqual(surface(account: .signedOut, readiness: .unavailable), .signInSetup)
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedOut, credentialSource: .managed, readiness: .unavailable),
|
||||
.signInSetup
|
||||
)
|
||||
}
|
||||
|
||||
func testSignedOutWithWorkingKeysOnlyGetsQuietPromo() {
|
||||
XCTAssertEqual(surface(account: .signedOut, readiness: .ownKey), .signInPromo)
|
||||
}
|
||||
|
||||
func testCreditsConnectionFailureBecomesProblemCard() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .failed),
|
||||
.creditsUnavailable
|
||||
)
|
||||
}
|
||||
|
||||
func testDepletedCreditsBecomeProblemCard() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .balance(0)),
|
||||
.creditsDepleted
|
||||
)
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .balance(-5)),
|
||||
.creditsDepleted
|
||||
)
|
||||
}
|
||||
|
||||
func testHealthyOrLoadingCreditsStayQuiet() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .balance(1_500)),
|
||||
.accountSummary
|
||||
)
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .loading),
|
||||
.accountSummary
|
||||
)
|
||||
}
|
||||
|
||||
func testSignedInWithoutKeysChoosesService() {
|
||||
XCTAssertEqual(surface(account: .signedIn, readiness: .dictationOnly), .chooseService)
|
||||
XCTAssertEqual(surface(account: .signedIn, readiness: .unavailable), .chooseService)
|
||||
}
|
||||
|
||||
func testSignedInWithOwnKeysIgnoresCreditErrors() {
|
||||
XCTAssertEqual(surface(account: .signedIn, readiness: .ownKey, credits: .failed), .accountSummary)
|
||||
XCTAssertEqual(surface(account: .signedIn, readiness: .ownKey, credits: .balance(0)), .accountSummary)
|
||||
}
|
||||
|
||||
func testSignedInIgnoresStalePendingInvitation() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, pendingReferralCode: "K7M2Q9XA", readiness: .ownKey),
|
||||
.accountSummary
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
func testUnfoldedDuoUsesWideLayoutEvenWithCompactSizeClass() {
|
||||
XCTAssertTrue(HomeLayoutPolicy.usesWideLayout(isRegularWidth: false, containerWidth: 669))
|
||||
XCTAssertFalse(HomeLayoutPolicy.usesWideLayout(isRegularWidth: false, containerWidth: 466))
|
||||
XCTAssertFalse(HomeLayoutPolicy.usesWideLayout(isRegularWidth: false, containerWidth: 402))
|
||||
}
|
||||
|
||||
func testRegularSizeClassKeepsWideLayoutInsideSplitView() {
|
||||
XCTAssertTrue(HomeLayoutPolicy.usesWideLayout(isRegularWidth: true, containerWidth: 504))
|
||||
}
|
||||
|
||||
func testWideTopClearanceOnlyWhereNoStatusBarSitsAbove() {
|
||||
// Landscape Duo inner display: status bar on the side, nothing above.
|
||||
XCTAssertEqual(WideLayoutMetrics.topCornerClearance(topSafeAreaInset: 0), Spacing.md)
|
||||
// iPad status bar, portrait Duo inner display (measured 82 pt).
|
||||
XCTAssertEqual(WideLayoutMetrics.topCornerClearance(topSafeAreaInset: 24), 0)
|
||||
XCTAssertEqual(WideLayoutMetrics.topCornerClearance(topSafeAreaInset: 82), 0)
|
||||
// A thin inset only gets topped up to the minimum.
|
||||
XCTAssertEqual(WideLayoutMetrics.topCornerClearance(topSafeAreaInset: 6), Spacing.md - 6)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func readiness(
|
||||
_ source: CredentialSource,
|
||||
account: HomeAccountPhase = .signedIn,
|
||||
asr: Bool = true,
|
||||
polish: Bool = true
|
||||
) -> HomeServiceReadiness {
|
||||
HomeAccountSurfacePolicy.readiness(
|
||||
credentialSource: source,
|
||||
account: account,
|
||||
isASRConfigured: asr,
|
||||
isPolishConfigured: polish
|
||||
)
|
||||
}
|
||||
|
||||
private func surface(
|
||||
needsPermissionSetup: Bool = false,
|
||||
account: HomeAccountPhase,
|
||||
pendingReferralCode: String? = nil,
|
||||
isInvitationDeferred: Bool = false,
|
||||
credentialSource: CredentialSource = .byok,
|
||||
readiness: HomeServiceReadiness = .ownKey,
|
||||
credits: HomeCreditsState = .loading
|
||||
) -> HomeAccountSurface? {
|
||||
HomeAccountSurfacePolicy.surface(
|
||||
needsPermissionSetup: needsPermissionSetup,
|
||||
account: account,
|
||||
pendingReferralCode: pendingReferralCode,
|
||||
isInvitationDeferred: isInvitationDeferred,
|
||||
credentialSource: credentialSource,
|
||||
readiness: readiness,
|
||||
credits: credits
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -131,10 +131,10 @@ final class AssistantKeyboardUITests: XCTestCase {
|
||||
XCTAssertTrue(element("assistant.edit", in: app).exists)
|
||||
}
|
||||
|
||||
func testClipboardSkillsPageHorizontallyAndDismissTogether() {
|
||||
func testClipboardSkillsAreTopBarCapsulesAndDismissTogether() {
|
||||
let app = launch(scenario: "skills")
|
||||
let pager = requiredElement("assistant.skills.pager", in: app)
|
||||
let navigate = element("assistant.skill.navigate", in: app)
|
||||
let reply = requiredElement("assistant.otherSkill.reply", in: app)
|
||||
let navigate = element("assistant.otherSkill.navigate", in: app)
|
||||
let sideActions = [
|
||||
requiredElement("assistant.delete", in: app),
|
||||
requiredElement("assistant.space", in: app),
|
||||
@@ -145,8 +145,11 @@ final class AssistantKeyboardUITests: XCTestCase {
|
||||
for action in sideActions {
|
||||
XCTAssertTrue(action.isHittable)
|
||||
}
|
||||
// Skills live in the top bar, above the mic; no circular chips remain.
|
||||
XCTAssertLessThan(reply.frame.maxY, requiredElement("assistant.mic.idle", in: app).frame.minY)
|
||||
XCTAssertFalse(element("assistant.skills.pager", in: app).exists)
|
||||
|
||||
pager.swipeLeft()
|
||||
reply.swipeLeft()
|
||||
|
||||
XCTAssertTrue(navigate.waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(navigate.isHittable)
|
||||
@@ -157,7 +160,7 @@ final class AssistantKeyboardUITests: XCTestCase {
|
||||
element("assistant.mic.idle", in: app)
|
||||
.waitForExistence(timeout: 3)
|
||||
)
|
||||
XCTAssertFalse(element("assistant.skills.pager", in: app).exists)
|
||||
XCTAssertFalse(element("assistant.otherSkill.navigate", in: app).exists)
|
||||
}
|
||||
|
||||
func testReliableSemanticResultShowsCompactBadge() {
|
||||
|
||||
+4
-4
@@ -11,10 +11,10 @@ OSGKeyboard is an AI voice keyboard for iPhone, iPad, and Mac: say it and it's t
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[Website](https://hkgood.github.io/OSGKeyboard/?lang=en) · [TestFlight Beta](https://testflight.apple.com/join/c2Bz4qK9) · [中文](./README.md) · [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/) · [Changelog](./CHANGELOG.md)
|
||||
[Website](https://osg.osglab.com/?lang=en) · [TestFlight Beta](https://testflight.apple.com/join/c2Bz4qK9) · [中文](./README.md) · [Privacy Policy](https://osg.osglab.com/privacy/) · [Changelog](./CHANGELOG.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://apps.apple.com/app/osgkeyboard/id6781553267">
|
||||
@@ -28,7 +28,7 @@ OSGKeyboard is an AI voice keyboard for iPhone, iPad, and Mac: say it and it's t
|
||||
|
||||
> The public Mac DMG is the signed and notarized historical version 1.1. The macOS target in this repository has moved forward with the 2.2 codebase; build with Xcode 26 for the latest source capabilities.
|
||||
>
|
||||
> The repository version follows [`project.yml`](./project.yml) (2.1.5, build 125). The `2.2.0` and later entries in CHANGELOG are already reflected in the capability descriptions below.
|
||||
> The repository version follows [`project.yml`](./project.yml) (2.1.6, build 131). The `2.1.6` and later entries in CHANGELOG are already reflected in the capability descriptions below.
|
||||
|
||||
## One keyboard, four ways to input
|
||||
|
||||
@@ -132,7 +132,7 @@ The iOS / iPadOS account center includes:
|
||||
- **Credential isolation** — user API keys stay in Keychain; account session tokens stay in the main app's private Keychain
|
||||
- **No ad tracking** — no advertising, analytics, or tracking SDKs, and no sale of personal data
|
||||
|
||||
See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/) for data categories, retention, account deletion, and third-party services.
|
||||
See the [Privacy Policy](https://osg.osglab.com/privacy/) for data categories, retention, account deletion, and third-party services.
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ OSGKeyboard 是面向 iPhone、iPad 与 Mac 的 AI 语音键盘:开口即文
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[官网](https://hkgood.github.io/OSGKeyboard/) · [TestFlight 公测](https://testflight.apple.com/join/c2Bz4qK9) · [English](./README.en.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) · [更新记录](./CHANGELOG.md)
|
||||
[官网](https://osg.osglab.com/) · [TestFlight 公测](https://testflight.apple.com/join/c2Bz4qK9) · [English](./README.en.md) · [隐私政策](https://osg.osglab.com/privacy/) · [更新记录](./CHANGELOG.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://apps.apple.com/cn/app/osgkeyboard/id6781553267">
|
||||
@@ -28,7 +28,7 @@ OSGKeyboard 是面向 iPhone、iPad 与 Mac 的 AI 语音键盘:开口即文
|
||||
|
||||
> 公开下载的 Mac DMG 是已签名并公证的历史版本 1.1。仓库中的 macOS target 随整体源码演进到 2.2;最新源码能力请用 Xcode 26 构建。
|
||||
>
|
||||
> 当前仓库版本以 [`project.yml`](./project.yml) 为准(`2.1.5` · build 125)。CHANGELOG 中 `2.2.0` 及更新的条目已经包含在本 README 的能力描述中。
|
||||
> 当前仓库版本以 [`project.yml`](./project.yml) 为准(`2.1.6` · build 131)。CHANGELOG 中 `2.1.6` 及更新的条目已经包含在本 README 的能力描述中。
|
||||
|
||||
## 一把键盘,四种输入方式
|
||||
|
||||
@@ -132,7 +132,7 @@ iOS / iPadOS 账号中心支持:
|
||||
- **凭证隔离**:用户 API Key 保存在 Keychain;账号会话令牌只在主 App 私有 Keychain
|
||||
- **无广告追踪**:不集成第三方分析、广告或追踪 SDK;可退出的第一方产品分析不采集输入内容,不出售个人数据
|
||||
|
||||
完整数据类型、保留策略、账号删除与第三方服务说明见[隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)。
|
||||
完整数据类型、保留策略、账号删除与第三方服务说明见[隐私政策](https://osg.osglab.com/privacy/)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hand a signed-in account session from a physical device to an iOS Simulator.
|
||||
#
|
||||
# The Simulator cannot produce DeviceCheck / App Attest evidence, so the
|
||||
# account server rejects its Apple sign-in (`integrity_evidence_missing`).
|
||||
# Refresh and managed-gateway calls only need the session, so a DEBUG build on
|
||||
# a device can relinquish its session instead:
|
||||
#
|
||||
# 1. Device (DEBUG build): Settings → Diagnostics → 把账号会话交给模拟器,
|
||||
# then AirDrop the file to this Mac.
|
||||
# 2. Mac: Scripts/sim-import-account-session.sh ~/Downloads/osg-account-handoff.json [udid]
|
||||
#
|
||||
# The DEBUG Simulator build must already be installed. The script places the
|
||||
# file in the app's Documents, deletes the source, and relaunches the app,
|
||||
# which adopts the session and deletes the file. The file holds a live refresh
|
||||
# token — treat it like a password.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 <osg-account-handoff.json> [simulator-udid|booted]" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[[ $# -ge 1 && $# -le 2 ]] || usage
|
||||
source_file=$1
|
||||
device=${2:-booted}
|
||||
bundle_id=com.osgkeyboard.ios
|
||||
format=osg-debug-account-handoff-v1
|
||||
|
||||
[[ -f "$source_file" ]] || { echo "error: $source_file not found" >&2; exit 66; }
|
||||
grep -q "\"$format\"" "$source_file" || {
|
||||
echo "error: $source_file is not an OSG account handoff file" >&2
|
||||
exit 65
|
||||
}
|
||||
|
||||
container=$(xcrun simctl get_app_container "$device" "$bundle_id" data) || {
|
||||
echo "error: $bundle_id is not installed on simulator '$device' — run the DEBUG build first" >&2
|
||||
exit 69
|
||||
}
|
||||
|
||||
mkdir -p "$container/Documents"
|
||||
install -m 600 "$source_file" "$container/Documents/osg-account-handoff.json"
|
||||
rm -f "$source_file"
|
||||
|
||||
xcrun simctl terminate "$device" "$bundle_id" >/dev/null 2>&1 || true
|
||||
xcrun simctl launch "$device" "$bundle_id" >/dev/null
|
||||
|
||||
echo "Session handed to simulator '$device'. The app adopts it on launch and deletes the file."
|
||||
@@ -26,6 +26,7 @@
|
||||
"platform": "ios",
|
||||
"tests": [
|
||||
"OSGKeyboardTests/AccountAPIClientTests",
|
||||
"OSGKeyboardTests/DebugAccountSessionHandoffTests",
|
||||
"OSGKeyboardTests/AccountCenterViewModelTests",
|
||||
"OSGKeyboardTests/AccountCreditPurchaseManagerTests",
|
||||
"OSGKeyboardTests/AccountSecurityPrimitiveTests",
|
||||
@@ -152,6 +153,7 @@
|
||||
"OSGKeyboardTests/FlowPiPLossReportGateTests",
|
||||
"OSGKeyboardTests/FlowSessionWarningReportGateTests",
|
||||
"OSGKeyboardTests/FlowHomePiPStatusPolicyTests",
|
||||
"OSGKeyboardTests/HomeAccountSurfacePolicyTests",
|
||||
"OSGKeyboardTests/FlowPiPRecoveryPolicyTests",
|
||||
"OSGKeyboardTests/FlowAdaptiveDownsamplerTests",
|
||||
"OSGKeyboardTests/FlowSessionBridgeTests",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 443 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
+33
-3
@@ -61,6 +61,30 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- v= 查询串用于缓存刷新:GitHub Pages 对静态资源有 10 分钟缓存窗口,改版本号强制浏览器取新文件 -->
|
||||
<link rel="stylesheet" href="assets/site.css?v=20261002a">
|
||||
<style>
|
||||
/* 工信部备案号:唯一「样式内联在页面里」的组件。
|
||||
原因:备案号是合规硬要求,首页必须始终正确展示。放进 assets/site.css 会带来两个
|
||||
部署风险——① 只同步了 index.html(漏传 CSS)时样式整段失效,备案号变成 16px 左对齐;
|
||||
② CSS 有浏览器/CDN 缓存,规则改了但用户看到的还是旧样式。
|
||||
所以这里用 <style> 块承载,并给 var() 写上兜底值,保证脱离 site.css 也能正常渲染。
|
||||
改动备案号时只改页脚那一个 <p>,样式规则保持在本块内。 */
|
||||
.footer-icp {
|
||||
width: var(--content, min(1120px, calc(100vw - 48px)));
|
||||
margin: 16px auto 0;
|
||||
color: var(--ink-2, #596067);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-icp a {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.footer-icp a:hover {
|
||||
color: var(--ink, #15181a);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// 首屏前确定主题,避免闪烁;无保存偏好时跟随系统。
|
||||
// .js 类只在脚本可用时出现:所有「先隐藏、再动效显现」的初始态都挂在它下面,
|
||||
@@ -93,7 +117,7 @@
|
||||
"operatingSystem": "iOS 26, iPadOS 26, macOS 15",
|
||||
"applicationCategory": "UtilitiesApplication",
|
||||
"applicationSubCategory": "Keyboard",
|
||||
"softwareVersion": "2.1.5",
|
||||
"softwareVersion": "2.1.6",
|
||||
"description": "OSGKeyboard is an agentic keyboard and AI voice keyboard (语音输入法 / 嘴替输入法) for iPhone, iPad, and Mac: on-device dictation by default, an AI assistant with preview-before-insert, a learned personal style that sounds like you, agentic clipboard skills that act on what you copy, Chinese and English typing, providers configured with your own API key, and optional managed credits. Source available, not open source.",
|
||||
"downloadUrl": "https://apps.apple.com/app/osgkeyboard/id6781553267",
|
||||
"installUrl": "https://apps.apple.com/app/osgkeyboard/id6781553267",
|
||||
@@ -899,8 +923,8 @@
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<span data-zh="OSGKeyboard 2.1.5 · AI 语音输入法 · 源码可见,非开源软件" data-en="OSGKeyboard 2.1.5 · AI voice keyboard · Source-available, not open source">
|
||||
OSGKeyboard 2.1.5 · AI 语音输入法 · 源码可见,非开源软件
|
||||
<span data-zh="OSGKeyboard 2.1.6 · AI 语音输入法 · 源码可见,非开源软件" data-en="OSGKeyboard 2.1.6 · AI voice keyboard · Source-available, not open source">
|
||||
OSGKeyboard 2.1.6 · AI 语音输入法 · 源码可见,非开源软件
|
||||
</span>
|
||||
<div class="footer-links">
|
||||
<a href="privacy/" data-zh="隐私政策" data-en="Privacy">隐私政策</a>
|
||||
@@ -909,6 +933,12 @@
|
||||
<a href="https://github.com/hkgood/OSGKeyboard" data-zh="源码" data-en="Source">源码</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 工信部要求:网站首页底部展示备案号,并链接到 beian.miit.gov.cn。
|
||||
备案号变更(接入商变更、主体变更、重新备案)后请同步更新这一行。
|
||||
样式见文件头部的 .footer-icp 规则块。 -->
|
||||
<p class="footer-icp">
|
||||
<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener nofollow">沪ICP备14049723号-2</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script src="assets/site.js?v=20261002a"></script>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> OSGKeyboard is an agentic keyboard and AI voice keyboard (Chinese: AI 语音输入法 / 嘴替输入法) for iPhone, iPad, and Mac. The iOS keyboard combines on-device dictation, a unified AI assistant, a learned personal style that sounds like you, agentic clipboard skills, Chinese and English typing, and optional managed credits. On Mac, hold Option for global dictation. It is source available, not open source.
|
||||
|
||||
Current repository version: 2.1.5 (build 125). Later work is listed under CHANGELOG [Unreleased].
|
||||
Current repository version: 2.1.6 (build 131). Later work is listed under CHANGELOG [Unreleased].
|
||||
|
||||
## Category definitions
|
||||
|
||||
|
||||
@@ -3,15 +3,6 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-H4WM522F4C"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-H4WM522F4C');
|
||||
</script>
|
||||
<title>OSGKeyboard — What's New / 更新说明</title>
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -198,6 +189,33 @@
|
||||
<p class="empty" id="empty-zh">暂无与当前版本匹配的更新说明。</p>
|
||||
<p class="empty" id="empty-en">No release notes match this app version.</p>
|
||||
|
||||
<!-- ========== 2.1.6 ========== -->
|
||||
<article class="version-block" data-version="2.1.6">
|
||||
<div class="version-chip">2.1.6</div>
|
||||
|
||||
<div class="lang-block" data-lang="zh">
|
||||
<section class="section">
|
||||
<ul>
|
||||
<li><strong>适配 iPhone Duo</strong>:展开内屏自动使用宽屏布局,合上后切回手机布局</li>
|
||||
<li><strong>优化横屏键盘</strong>:iPad 与 iPhone Duo 的语音键盘底部改为系统风格按键行,iPhone 横放时键盘更矮</li>
|
||||
<li><strong>优化首页顶部</strong>:最多只显示一张卡片,缺少权限、未登录或积分用完时直接给出下一步入口</li>
|
||||
<li><strong>新增剪贴板 AI 技能</strong>:其他语言的内容可一键“优化”,当前语言的内容可“翻译 XX”译成目标语言</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="lang-block" data-lang="en">
|
||||
<section class="section">
|
||||
<ul>
|
||||
<li><strong>iPhone Duo support</strong>: the unfolded inner display uses the wide layout automatically and returns to the phone layout when folded</li>
|
||||
<li><strong>Better landscape keyboards</strong>: on iPad and iPhone Duo the voice keyboard ends with a row of system-style keys, and the keyboard is shorter on an iPhone held sideways</li>
|
||||
<li><strong>Cleaner top of Home</strong>: at most one card is shown, with a clear next step when permissions are missing, you are signed out, or credits run out</li>
|
||||
<li><strong>New clipboard AI skills</strong>: “Polish” for text in other languages, and “Translate to XX” for text in your current language</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- ========== 2.1.0 ========== -->
|
||||
<!-- Cumulative notes for users upgrading from 1.8.0: 2.0.x were skipped as -->
|
||||
<!-- public releases, so all of their user-facing changes ship here. -->
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<p>User-configured cloud ASR and LLM requests go directly to the provider you configure. Managed-credit requests go through <code>account.osglab.com</code> to the managed ASR or LLM provider. Apple processes Sign in with Apple and StoreKit purchases. AI skills may also hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and update pages are served by <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each service's privacy policy applies.</p>
|
||||
|
||||
<h2>Website analytics</h2>
|
||||
<p>The OSGKeyboard website at <code>hkgood.github.io/OSGKeyboard</code> — including the in-app What's New page loaded from that site — uses Google Analytics 4 (gtag.js) to measure page visits, approximate region, and device or browser type. It runs only on these web pages: it is not an app SDK, never sees keyboard input, audio, transcripts, clipboard content, prompts, or API keys, and is not controlled by the in-app analytics toggle. Data processed by Google is governed by <a href="https://policies.google.com/privacy">Google's privacy policy</a>; you can block it with a browser content blocker or <a href="https://tools.google.com/dlpage/gaoptout">Google's opt-out add-on</a>.</p>
|
||||
<p>The OSGKeyboard website at <code>osg.osglab.com</code> uses Google Analytics 4 (gtag.js) to measure page visits, approximate region, and device or browser type. It runs only on these web pages: it is not an app SDK, never sees keyboard input, audio, transcripts, clipboard content, prompts, or API keys, and is not controlled by the in-app analytics toggle. The in-app What's New page is served from <code>download.osglab.com</code> and does not run Google Analytics. Data processed by Google is governed by <a href="https://policies.google.com/privacy">Google's privacy policy</a>; you can block it with a browser content blocker or <a href="https://tools.google.com/dlpage/gaoptout">Google's opt-out add-on</a>.</p>
|
||||
|
||||
<h2>Data retention</h2>
|
||||
<p>Settings remain on your device until you delete the app or reset settings. When iCloud settings sync is enabled, API keys replicate through iCloud Keychain and eligible preferences, statistics, dictionary data, voice history, and saved custom styles may sync through your private iCloud account. Voice history is capped at 300 entries and can be cleared from History or by resetting settings; clearing all history also removes retained style-prompt snapshots. A generated style is stored only after you review and save it. Clipboard history remains device-local, is capped at 15 entries, has no fixed expiry, and remains when capture is turned off or settings are reset; use the separate confirmed clear action to delete it. Chinese and English typing-learning data stays local and can be cleared from typing settings without deleting the personal dictionary. Downloaded Mac speech models remain until you remove the model in the app or delete its local files.</p>
|
||||
@@ -156,7 +156,7 @@
|
||||
<p>用户自备的云端 ASR 与 LLM 请求直接发送到你配置的服务商;托管积分请求经 <code>account.osglab.com</code> 发送到托管 ASR 或 LLM 服务商。Apple 处理 Apple 登录与 StoreKit 购买。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code>、<code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各服务的隐私政策适用。</p>
|
||||
|
||||
<h2>网站分析</h2>
|
||||
<p>OSGKeyboard 网站(<code>hkgood.github.io/OSGKeyboard</code>,包括 App 内从该站点加载的「更新说明」页面)使用 Google Analytics 4(gtag.js)统计页面访问量、大致地区与设备或浏览器类型。它仅运行在这些网页中:不是 App 内 SDK,无法接触键盘输入、语音、转写文本、剪贴板内容、提示词或 API 密钥,也不受 App 内「产品分析」开关控制。Google 处理的数据适用其<a href="https://policies.google.com/privacy">隐私政策</a>;你可以通过浏览器内容拦截器或 Google 的<a href="https://tools.google.com/dlpage/gaoptout">停用插件</a>将其屏蔽。</p>
|
||||
<p>OSGKeyboard 网站(<code>osg.osglab.com</code>)使用 Google Analytics 4(gtag.js)统计页面访问量、大致地区与设备或浏览器类型。它仅运行在这些网页中:不是 App 内 SDK,无法接触键盘输入、语音、转写文本、剪贴板内容、提示词或 API 密钥,也不受 App 内「产品分析」开关控制。App 内「更新说明」页面由 <code>download.osglab.com</code> 提供,不运行 Google Analytics。Google 处理的数据适用其<a href="https://policies.google.com/privacy">隐私政策</a>;你可以通过浏览器内容拦截器或 Google 的<a href="https://tools.google.com/dlpage/gaoptout">停用插件</a>将其屏蔽。</p>
|
||||
|
||||
<h2>数据保留</h2>
|
||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,可同步的偏好、统计、词库、语音历史和已保存的自定义风格可能经私有 iCloud 账户同步。语音历史上限 300 条,可在「历史」页清空或通过重置设置清除;清空全部历史也会删除保留的风格 Prompt 快照。生成的风格只有在你检查并保存后才会写入。剪贴板历史仅保存在本机,上限 15 条,没有固定过期时间;关闭采集或重置设置均会保留,需使用单独确认的清空操作删除。中英文输入学习数据仅留本机,可在输入设置中单独清除且不影响个性词库。下载的 Mac 语音模型会保留到你在应用中移除模型或删除本地文件。</p>
|
||||
|
||||
+3
-2
@@ -48,8 +48,8 @@ settings:
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "2.1.5"
|
||||
CURRENT_PROJECT_VERSION: "125"
|
||||
MARKETING_VERSION: "2.1.6"
|
||||
CURRENT_PROJECT_VERSION: "131"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
@@ -598,6 +598,7 @@ targets:
|
||||
- path: OSGKeyboard/Views/Account/AccountCreditStore.swift
|
||||
- path: OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift
|
||||
- path: OSGKeyboard/Views/Account/LiveAccountServices.swift
|
||||
- path: OSGKeyboard/Views/Account/DebugAccountSessionHandoff.swift
|
||||
- path: OSGKeyboard/Views/Account/ReferralProfileViewModel.swift
|
||||
# Invitation QR poster: pure SwiftUI + Core Image, shared with iOS.
|
||||
- path: OSGKeyboard/Views/Account/InvitePoster.swift
|
||||
|
||||
Reference in New Issue
Block a user